feat(home): 응원 스냅샷 갱신 lock을 적용한다
This commit is contained in:
@@ -48,7 +48,7 @@ open class RecommendationSnapshotRefreshService(
|
|||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now)
|
val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now)
|
||||||
val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt)
|
val cheerCreatorCount = refreshCheerCreatorSnapshotsWithSectionLock(now)
|
||||||
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt)
|
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt)
|
||||||
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
|
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
|
||||||
}.onSuccess { counts ->
|
}.onSuccess { counts ->
|
||||||
@@ -95,14 +95,38 @@ open class RecommendationSnapshotRefreshService(
|
|||||||
return snapshots.size
|
return snapshots.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
|
val window = windowPolicy.previousKstDayUtcWindow(nowUtc)
|
||||||
|
val snapshots = queryPort.findCheerCreatorSnapshots(
|
||||||
|
window.startUtc,
|
||||||
|
window.endExclusiveUtc,
|
||||||
|
CHEER_CREATOR_SNAPSHOT_LIMIT
|
||||||
|
)
|
||||||
|
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots)
|
||||||
|
afterCommit {
|
||||||
|
log.info(
|
||||||
|
"event=cheer_creator_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 {
|
private fun refreshAiCharacterSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
|
||||||
val client = redissonClient ?: return refreshAiCharacterSnapshots(nowUtc)
|
val client = redissonClient ?: return refreshAiCharacterSnapshots(nowUtc)
|
||||||
val lock = client.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)
|
val lock = client.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
|
||||||
|
|
||||||
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
|
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
|
||||||
log.info(
|
log.info(
|
||||||
"event=ai_character_recommendation_snapshot_refresh_lock_missed lockKey={}",
|
"event=ai_character_recommendation_snapshot_refresh_lock_missed lockKey={}",
|
||||||
AiCharacterSnapshotFallbackService.LOCK_KEY
|
RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -111,6 +135,22 @@ open class RecommendationSnapshotRefreshService(
|
|||||||
return refreshAiCharacterSnapshots(nowUtc)
|
return refreshAiCharacterSnapshots(nowUtc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshCheerCreatorSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
|
||||||
|
val client = redissonClient ?: return refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
val lock = client.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)
|
||||||
|
|
||||||
|
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
|
||||||
|
log.info(
|
||||||
|
"event=cheer_creator_recommendation_snapshot_refresh_lock_missed lockKey={}",
|
||||||
|
RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockAfterTransaction(lock)
|
||||||
|
return refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
}
|
||||||
|
|
||||||
private fun unlockAfterTransaction(lock: org.redisson.api.RLock) {
|
private fun unlockAfterTransaction(lock: org.redisson.api.RLock) {
|
||||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
if (lock.isHeldByCurrentThread) {
|
if (lock.isHeldByCurrentThread) {
|
||||||
@@ -130,12 +170,6 @@ open class RecommendationSnapshotRefreshService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
return snapshots.size
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
||||||
val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT)
|
val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT)
|
||||||
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots)
|
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots)
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
|
Mockito.`when`(queryPort.findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)).thenReturn(
|
||||||
listOf(
|
listOf(
|
||||||
RecommendationSnapshotRecord(
|
RecommendationSnapshotRecord(
|
||||||
sectionType = RecommendedSectionType.CHEER_CREATOR,
|
sectionType = RecommendedSectionType.CHEER_CREATOR,
|
||||||
targetId = 22L,
|
targetId = 22L,
|
||||||
score = 792.22,
|
score = 792.22,
|
||||||
snapshotAt = snapshotAt,
|
snapshotAt = aiSnapshotAt,
|
||||||
randomTieBreaker = 0.2
|
randomTieBreaker = 0.2
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -111,7 +111,7 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
assertEquals(40.0, communitySnapshots.single().score, 0.0001)
|
assertEquals(40.0, communitySnapshots.single().score, 0.0001)
|
||||||
|
|
||||||
Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)
|
Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)
|
||||||
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16)
|
Mockito.verify(queryPort).findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)
|
||||||
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20)
|
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20)
|
||||||
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
|
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
|
||||||
assertEquals(true, output.out.contains("aiCharacterCount=1"))
|
assertEquals(true, output.out.contains("aiCharacterCount=1"))
|
||||||
@@ -132,7 +132,13 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
20
|
20
|
||||||
)
|
)
|
||||||
).thenReturn(emptyList())
|
).thenReturn(emptyList())
|
||||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList())
|
Mockito.`when`(
|
||||||
|
queryPort.findCheerCreatorSnapshots(
|
||||||
|
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
|
||||||
|
16
|
||||||
|
)
|
||||||
|
).thenReturn(emptyList())
|
||||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||||
|
|
||||||
TransactionSynchronizationManager.initSynchronization()
|
TransactionSynchronizationManager.initSynchronization()
|
||||||
@@ -169,7 +175,13 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
).thenReturn(
|
).thenReturn(
|
||||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = aiSnapshotAt))
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = aiSnapshotAt))
|
||||||
)
|
)
|
||||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
|
Mockito.`when`(
|
||||||
|
queryPort.findCheerCreatorSnapshots(
|
||||||
|
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||||
|
aiWindowEndExclusive,
|
||||||
|
16
|
||||||
|
)
|
||||||
|
).thenReturn(
|
||||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt))
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt))
|
||||||
)
|
)
|
||||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
||||||
@@ -190,13 +202,17 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
val lock = Mockito.mock(RLock::class.java)
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val cheerLock = Mockito.mock(RLock::class.java)
|
||||||
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
|
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
|
||||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
|
||||||
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
|
||||||
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
|
||||||
Mockito.`when`(
|
Mockito.`when`(
|
||||||
queryPort.findAiCharacterSnapshots(
|
queryPort.findAiCharacterSnapshots(
|
||||||
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||||
@@ -204,12 +220,18 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
20
|
20
|
||||||
)
|
)
|
||||||
).thenReturn(emptyList())
|
).thenReturn(emptyList())
|
||||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList())
|
Mockito.`when`(
|
||||||
|
queryPort.findCheerCreatorSnapshots(
|
||||||
|
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
|
||||||
|
16
|
||||||
|
)
|
||||||
|
).thenReturn(emptyList())
|
||||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||||
|
|
||||||
service.refreshDailySnapshots(now)
|
service.refreshDailySnapshots(now)
|
||||||
|
|
||||||
Mockito.verify(redissonClient).getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)
|
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
|
||||||
Mockito.verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS)
|
Mockito.verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS)
|
||||||
Mockito.verify(queryPort).findAiCharacterSnapshots(
|
Mockito.verify(queryPort).findAiCharacterSnapshots(
|
||||||
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||||
@@ -217,6 +239,78 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
20
|
20
|
||||||
)
|
)
|
||||||
Mockito.verify(lock).unlock()
|
Mockito.verify(lock).unlock()
|
||||||
|
Mockito.verify(cheerLock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일 스냅샷 갱신은 응원 크리에이터 섹션 lock을 획득한 경우에만 응원 refresh를 실행한다")
|
||||||
|
fun shouldRefreshCheerCreatorSectionOnlyWhenSectionLockAcquired() {
|
||||||
|
val snapshotPort = FakeRecommendationSnapshotPort()
|
||||||
|
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val aiLock = Mockito.mock(RLock::class.java)
|
||||||
|
val cheerLock = 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 windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
val cheerWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0)
|
||||||
|
val cheerWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||||
|
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(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
||||||
|
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false)
|
||||||
|
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.`when`(queryPort.findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)).thenReturn(
|
||||||
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 10L, score = 10.0, snapshotAt = snapshotAt))
|
||||||
|
)
|
||||||
|
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||||
|
|
||||||
|
service.refreshDailySnapshots(now)
|
||||||
|
|
||||||
|
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)
|
||||||
|
Mockito.verify(cheerLock).tryLock(0, -1, TimeUnit.MILLISECONDS)
|
||||||
|
Mockito.verify(queryPort).findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)
|
||||||
|
assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
|
||||||
|
Mockito.verify(cheerLock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일 스냅샷 갱신은 응원 크리에이터 섹션 lock 획득 실패 시 응원 refresh를 건너뛰고 다른 섹션은 유지한다")
|
||||||
|
fun shouldSkipOnlyCheerCreatorRefreshWhenSectionLockNotAcquired() {
|
||||||
|
val snapshotPort = FakeRecommendationSnapshotPort()
|
||||||
|
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val aiLock = Mockito.mock(RLock::class.java)
|
||||||
|
val cheerLock = 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(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
|
||||||
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
|
||||||
|
Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
||||||
|
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false)
|
||||||
|
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
||||||
|
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(false)
|
||||||
|
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
||||||
|
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 20L, score = 20.0, snapshotAt = snapshotAt))
|
||||||
|
)
|
||||||
|
|
||||||
|
service.refreshDailySnapshots(now)
|
||||||
|
|
||||||
|
Mockito.verify(queryPort, Mockito.never()).findCheerCreatorSnapshots(
|
||||||
|
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
|
||||||
|
16
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
emptyList<RecommendationSnapshotRecord>(),
|
||||||
|
snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
|
||||||
|
)
|
||||||
|
assertEquals(listOf(20L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
|
||||||
|
Mockito.verify(cheerLock, Mockito.never()).unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -226,14 +320,24 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
val lock = Mockito.mock(RLock::class.java)
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val cheerLock = Mockito.mock(RLock::class.java)
|
||||||
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
|
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
|
||||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
|
||||||
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
|
||||||
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
||||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
|
||||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
|
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.`when`(
|
||||||
|
queryPort.findCheerCreatorSnapshots(
|
||||||
|
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
|
||||||
|
16
|
||||||
|
)
|
||||||
|
).thenReturn(
|
||||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt))
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt))
|
||||||
)
|
)
|
||||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||||
@@ -251,6 +355,7 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
)
|
)
|
||||||
assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
|
assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
|
||||||
Mockito.verify(lock, Mockito.never()).unlock()
|
Mockito.verify(lock, Mockito.never()).unlock()
|
||||||
|
Mockito.verify(cheerLock).unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -360,17 +465,35 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
|||||||
return all.drop(offset.toInt()).take(limit)
|
return all.drop(offset.toInt()).take(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findSnapshots(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
val all = snapshots
|
||||||
|
.filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && 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 {
|
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
|
||||||
return snapshots.any { it.sectionType == sectionType }
|
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(
|
override fun replaceSnapshots(
|
||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
newSnapshots: List<RecommendationSnapshotRecord>
|
newSnapshots: List<RecommendationSnapshotRecord>
|
||||||
) {
|
) {
|
||||||
if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) {
|
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
|
||||||
snapshots.removeIf { it.sectionType == sectionType }
|
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
snapshots.add(
|
snapshots.add(
|
||||||
RecommendationSnapshotRecord(
|
RecommendationSnapshotRecord(
|
||||||
sectionType = sectionType,
|
sectionType = sectionType,
|
||||||
@@ -386,4 +509,8 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
|||||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
snapshots.addAll(newSnapshots)
|
snapshots.addAll(newSnapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
|
||||||
|
return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user