test #433
@@ -1,9 +1,11 @@
|
||||
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.HomeRecommendationQueryPort
|
||||
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.annotation.Transactional
|
||||
@@ -11,13 +13,17 @@ import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Service
|
||||
class RecommendationSnapshotRefreshService(
|
||||
open class RecommendationSnapshotRefreshService(
|
||||
private val snapshotPort: RecommendationSnapshotPort,
|
||||
private val queryPort: HomeRecommendationQueryPort
|
||||
private val queryPort: HomeRecommendationQueryPort,
|
||||
private val redissonClient: RedissonClient? = null
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val windowPolicy = RecommendationSnapshotWindowPolicy()
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
|
||||
@@ -26,7 +32,7 @@ class RecommendationSnapshotRefreshService(
|
||||
|
||||
@Transactional
|
||||
fun refreshDailySnapshots() {
|
||||
refreshDailySnapshots(LocalDateTime.now())
|
||||
refreshDailySnapshots(LocalDateTime.now(ZoneOffset.UTC))
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -41,7 +47,7 @@ class RecommendationSnapshotRefreshService(
|
||||
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
|
||||
|
||||
runCatching {
|
||||
val aiCharacterCount = replaceAiCharacterSnapshots(windowStart, snapshotAt)
|
||||
val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now)
|
||||
val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt)
|
||||
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt)
|
||||
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
|
||||
@@ -69,12 +75,61 @@ class RecommendationSnapshotRefreshService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
||||
val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT)
|
||||
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots)
|
||||
@Transactional
|
||||
open fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val window = windowPolicy.previousKstDayUtcWindow(nowUtc)
|
||||
val snapshots = queryPort.findAiCharacterSnapshots(window.startUtc, window.endExclusiveUtc, AI_CHARACTER_SNAPSHOT_LIMIT)
|
||||
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, window.snapshotAt, snapshots)
|
||||
afterCommit {
|
||||
log.info(
|
||||
"event=ai_character_recommendation_snapshot_refresh_success " +
|
||||
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}",
|
||||
window.snapshotAt,
|
||||
window.startUtc,
|
||||
window.endExclusiveUtc,
|
||||
snapshots.size,
|
||||
System.currentTimeMillis() - startedAt
|
||||
)
|
||||
}
|
||||
return snapshots.size
|
||||
}
|
||||
|
||||
private fun refreshAiCharacterSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
|
||||
val client = redissonClient ?: return refreshAiCharacterSnapshots(nowUtc)
|
||||
val lock = client.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)
|
||||
|
||||
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
|
||||
log.info(
|
||||
"event=ai_character_recommendation_snapshot_refresh_lock_missed lockKey={}",
|
||||
AiCharacterSnapshotFallbackService.LOCK_KEY
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
unlockAfterTransaction(lock)
|
||||
return refreshAiCharacterSnapshots(nowUtc)
|
||||
}
|
||||
|
||||
private fun unlockAfterTransaction(lock: org.redisson.api.RLock) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
if (lock.isHeldByCurrentThread) {
|
||||
lock.unlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCompletion(status: Int) {
|
||||
if (lock.isHeldByCurrentThread) {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
||||
val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT)
|
||||
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots)
|
||||
|
||||
@@ -55,14 +55,17 @@ class RecommendationSnapshotRefreshServiceTest {
|
||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||
val aiWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0)
|
||||
val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
|
||||
val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
|
||||
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
||||
Mockito.`when`(queryPort.findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)).thenReturn(
|
||||
listOf(
|
||||
RecommendationSnapshotRecord(
|
||||
sectionType = RecommendedSectionType.AI_CHARACTER,
|
||||
targetId = 11L,
|
||||
score = 78.0,
|
||||
snapshotAt = snapshotAt,
|
||||
snapshotAt = aiSnapshotAt,
|
||||
randomTieBreaker = 0.1
|
||||
)
|
||||
)
|
||||
@@ -96,7 +99,7 @@ class RecommendationSnapshotRefreshServiceTest {
|
||||
val cheerSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
|
||||
val communitySnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
|
||||
|
||||
assertEquals(snapshotAt, aiSnapshots.single().snapshotAt)
|
||||
assertEquals(aiSnapshotAt, aiSnapshots.single().snapshotAt)
|
||||
assertEquals(11L, aiSnapshots.single().targetId)
|
||||
assertEquals(78.0, aiSnapshots.single().score, 0.0001)
|
||||
assertEquals(0.1, aiSnapshots.single().randomTieBreaker, 0.0001)
|
||||
@@ -107,7 +110,7 @@ class RecommendationSnapshotRefreshServiceTest {
|
||||
assertEquals(33L, communitySnapshots.single().targetId)
|
||||
assertEquals(40.0, communitySnapshots.single().score, 0.0001)
|
||||
|
||||
Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20)
|
||||
Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)
|
||||
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16)
|
||||
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20)
|
||||
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
|
||||
@@ -122,7 +125,13 @@ class RecommendationSnapshotRefreshServiceTest {
|
||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||
Mockito.`when`(
|
||||
queryPort.findAiCharacterSnapshots(
|
||||
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
|
||||
20
|
||||
)
|
||||
).thenReturn(emptyList())
|
||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList())
|
||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||
|
||||
@@ -148,9 +157,17 @@ class RecommendationSnapshotRefreshServiceTest {
|
||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||
val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
|
||||
val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
|
||||
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = snapshotAt))
|
||||
Mockito.`when`(
|
||||
queryPort.findAiCharacterSnapshots(
|
||||
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||
aiWindowEndExclusive,
|
||||
20
|
||||
)
|
||||
).thenReturn(
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = aiSnapshotAt))
|
||||
)
|
||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
|
||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt))
|
||||
@@ -166,6 +183,76 @@ class RecommendationSnapshotRefreshServiceTest {
|
||||
assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다")
|
||||
fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() {
|
||||
val snapshotPort = FakeRecommendationSnapshotPort()
|
||||
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
|
||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
||||
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||
Mockito.`when`(
|
||||
queryPort.findAiCharacterSnapshots(
|
||||
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
|
||||
20
|
||||
)
|
||||
).thenReturn(emptyList())
|
||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList())
|
||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
Mockito.verify(redissonClient).getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)
|
||||
Mockito.verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS)
|
||||
Mockito.verify(queryPort).findAiCharacterSnapshots(
|
||||
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
|
||||
20
|
||||
)
|
||||
Mockito.verify(lock).unlock()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock 획득 실패 시 AI refresh만 건너뛰고 다른 섹션은 유지한다")
|
||||
fun shouldSkipOnlyAiCharacterRefreshWhenSectionLockNotAcquired() {
|
||||
val snapshotPort = FakeRecommendationSnapshotPort()
|
||||
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
|
||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
||||
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
|
||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
|
||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt))
|
||||
)
|
||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
Mockito.verify(queryPort, Mockito.never()).findAiCharacterSnapshots(
|
||||
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
|
||||
20
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<RecommendationSnapshotRecord>(),
|
||||
snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER)
|
||||
)
|
||||
assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
|
||||
Mockito.verify(lock, Mockito.never()).unlock()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 스냅샷 스케줄러는 매일 06:00 KST cron으로 갱신 서비스를 호출한다")
|
||||
fun shouldScheduleDailySnapshotRefreshAtKstSix() {
|
||||
@@ -227,11 +314,13 @@ class RecommendationSnapshotRefreshServiceTest {
|
||||
|
||||
private fun service(
|
||||
snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(),
|
||||
queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||
queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java),
|
||||
redissonClient: RedissonClient? = null
|
||||
): RecommendationSnapshotRefreshService {
|
||||
return RecommendationSnapshotRefreshService(
|
||||
snapshotPort = snapshotPort,
|
||||
queryPort = queryPort
|
||||
queryPort = queryPort,
|
||||
redissonClient = redissonClient
|
||||
)
|
||||
}
|
||||
|
||||
@@ -264,18 +353,36 @@ private class FakeRecommendationSnapshotPort : 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(
|
||||
RecommendationSnapshotRecord(
|
||||
sectionType = sectionType,
|
||||
targetId = 0L,
|
||||
score = 0.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.0
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user