feat(recommend): 추천 스냅샷 성공 로그를 커밋 후 기록한다
This commit is contained in:
@@ -4,8 +4,11 @@ import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
|||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
||||||
@@ -14,6 +17,8 @@ class RecommendationSnapshotRefreshService(
|
|||||||
private val snapshotPort: RecommendationSnapshotPort,
|
private val snapshotPort: RecommendationSnapshotPort,
|
||||||
private val queryPort: HomeRecommendationQueryPort
|
private val queryPort: HomeRecommendationQueryPort
|
||||||
) {
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
|
fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
|
||||||
return snapshotPort.findLatestSnapshots(sectionType)
|
return snapshotPort.findLatestSnapshots(sectionType)
|
||||||
@@ -26,6 +31,7 @@ class RecommendationSnapshotRefreshService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun refreshDailySnapshots(now: LocalDateTime) {
|
fun refreshDailySnapshots(now: LocalDateTime) {
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
val snapshotAt = now
|
val snapshotAt = now
|
||||||
.atZone(UTC_ZONE)
|
.atZone(UTC_ZONE)
|
||||||
.withZoneSameInstant(KST_ZONE)
|
.withZoneSameInstant(KST_ZONE)
|
||||||
@@ -34,24 +40,69 @@ class RecommendationSnapshotRefreshService(
|
|||||||
.atTime(23, 59, 59)
|
.atTime(23, 59, 59)
|
||||||
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
|
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
|
||||||
|
|
||||||
replaceAiCharacterSnapshots(windowStart, snapshotAt)
|
runCatching {
|
||||||
replaceCheerCreatorSnapshots(windowStart, snapshotAt)
|
val aiCharacterCount = replaceAiCharacterSnapshots(windowStart, snapshotAt)
|
||||||
replacePopularCommunitySnapshots(windowStart, snapshotAt)
|
val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt)
|
||||||
|
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt)
|
||||||
|
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
|
||||||
|
}.onSuccess { counts ->
|
||||||
|
afterCommit {
|
||||||
|
log.info(
|
||||||
|
"event=recommendation_snapshot_refresh_success " +
|
||||||
|
"snapshotAt={} aiCharacterCount={} cheerCreatorCount={} popularCommunityCount={} elapsedMs={}",
|
||||||
|
snapshotAt,
|
||||||
|
counts.aiCharacterCount,
|
||||||
|
counts.cheerCreatorCount,
|
||||||
|
counts.popularCommunityCount,
|
||||||
|
System.currentTimeMillis() - startedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure { ex ->
|
||||||
|
log.warn(
|
||||||
|
"event=recommendation_snapshot_refresh_failure snapshotAt={} elapsedMs={} error={}",
|
||||||
|
snapshotAt,
|
||||||
|
System.currentTimeMillis() - startedAt,
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
throw ex
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) {
|
private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
||||||
val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT)
|
val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT)
|
||||||
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots)
|
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots)
|
||||||
|
return snapshots.size
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) {
|
private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
||||||
val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT)
|
val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT)
|
||||||
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots)
|
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots)
|
||||||
|
return snapshots.size
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) {
|
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)
|
||||||
|
return snapshots.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RefreshCounts(
|
||||||
|
val aiCharacterCount: Int,
|
||||||
|
val cheerCreatorCount: Int,
|
||||||
|
val popularCommunityCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun afterCommit(action: () -> Unit) {
|
||||||
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
action()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(
|
||||||
|
object : TransactionSynchronization {
|
||||||
|
override fun afterCommit() = action()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotReco
|
|||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@ExtendWith(OutputCaptureExtension::class)
|
||||||
class RecommendationSnapshotRefreshServiceTest {
|
class RecommendationSnapshotRefreshServiceTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("섹션의 최신 기준 시각 스냅샷만 반환하고 없으면 빈 배열을 반환한다")
|
@DisplayName("섹션의 최신 기준 시각 스냅샷만 반환하고 없으면 빈 배열을 반환한다")
|
||||||
@@ -40,7 +45,7 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다")
|
@DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다")
|
||||||
fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt() {
|
fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt(output: CapturedOutput) {
|
||||||
val snapshotPort = FakeRecommendationSnapshotPort()
|
val snapshotPort = FakeRecommendationSnapshotPort()
|
||||||
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||||
val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
|
val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
|
||||||
@@ -102,6 +107,33 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20)
|
Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20)
|
||||||
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16)
|
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 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("aiCharacterCount=1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일 스냅샷 갱신 성공 로그는 트랜잭션 커밋 후 기록한다")
|
||||||
|
fun shouldLogSnapshotRefreshSuccessAfterTransactionCommit(output: CapturedOutput) {
|
||||||
|
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||||
|
val service = service(queryPort = queryPort)
|
||||||
|
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.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList())
|
||||||
|
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.initSynchronization()
|
||||||
|
try {
|
||||||
|
service.refreshDailySnapshots(now)
|
||||||
|
|
||||||
|
assertEquals(false, output.out.contains("event=recommendation_snapshot_refresh_success"))
|
||||||
|
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
|
||||||
|
} finally {
|
||||||
|
TransactionSynchronizationManager.clearSynchronization()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user