feat(home): 응원 스냅샷 갱신 lock을 적용한다

This commit is contained in:
2026-07-10 06:06:30 +09:00
parent 64b05deee7
commit ce43cf2c67
2 changed files with 182 additions and 21 deletions
@@ -70,13 +70,13 @@ class RecommendationSnapshotRefreshServiceTest {
)
)
)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
Mockito.`when`(queryPort.findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)).thenReturn(
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.CHEER_CREATOR,
targetId = 22L,
score = 792.22,
snapshotAt = snapshotAt,
snapshotAt = aiSnapshotAt,
randomTieBreaker = 0.2
)
)
@@ -111,7 +111,7 @@ class RecommendationSnapshotRefreshServiceTest {
assertEquals(40.0, communitySnapshots.single().score, 0.0001)
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)
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
assertEquals(true, output.out.contains("aiCharacterCount=1"))
@@ -132,7 +132,13 @@ class RecommendationSnapshotRefreshServiceTest {
20
)
).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())
TransactionSynchronizationManager.initSynchronization()
@@ -169,7 +175,13 @@ class RecommendationSnapshotRefreshServiceTest {
).thenReturn(
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))
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
@@ -190,13 +202,17 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val redissonClient = Mockito.mock(RedissonClient::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 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`(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.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(
queryPort.findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
@@ -204,12 +220,18 @@ class RecommendationSnapshotRefreshServiceTest {
20
)
).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())
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(queryPort).findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
@@ -217,6 +239,78 @@ class RecommendationSnapshotRefreshServiceTest {
20
)
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
@@ -226,14 +320,24 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val redissonClient = Mockito.mock(RedissonClient::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 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`(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.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))
)
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 })
Mockito.verify(lock, Mockito.never()).unlock()
Mockito.verify(cheerLock).unlock()
}
@Test
@@ -360,17 +465,35 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
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 {
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() && sectionType == RecommendedSectionType.AI_CHARACTER) {
snapshots.removeIf { it.sectionType == sectionType }
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.add(
RecommendationSnapshotRecord(
sectionType = sectionType,
@@ -386,4 +509,8 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
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
}
}