fix(home-following): inbox 중복 insert 처리를 보강한다
This commit is contained in:
@@ -12,6 +12,22 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository<HomeFollowingNewsI
|
|||||||
sourceKey: String
|
sourceKey: String
|
||||||
): Boolean
|
): Boolean
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
select member_id
|
||||||
|
from home_following_news_inbox
|
||||||
|
where news_type = :newsType
|
||||||
|
and source_key = :sourceKey
|
||||||
|
and member_id in :memberIds
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
fun findExistingMemberIds(
|
||||||
|
@Param("newsType") newsType: String,
|
||||||
|
@Param("sourceKey") sourceKey: String,
|
||||||
|
@Param("memberIds") memberIds: Collection<Long>
|
||||||
|
): List<Long>
|
||||||
|
|
||||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
@Query(
|
@Query(
|
||||||
value = """
|
value = """
|
||||||
|
|||||||
@@ -5,23 +5,33 @@ import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInbo
|
|||||||
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
|
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
|
||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager
|
||||||
|
import org.springframework.transaction.TransactionDefinition
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
import javax.persistence.EntityManager
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class HomeFollowingNewsInboxPersistenceAdapter(
|
class HomeFollowingNewsInboxPersistenceAdapter(
|
||||||
private val repository: HomeFollowingNewsInboxJpaRepository,
|
private val repository: HomeFollowingNewsInboxJpaRepository,
|
||||||
private val entityManager: EntityManager
|
private val entityManager: EntityManager,
|
||||||
|
transactionManager: PlatformTransactionManager? = null
|
||||||
) : HomeFollowingNewsInboxPort {
|
) : HomeFollowingNewsInboxPort {
|
||||||
@Transactional
|
private val transactionTemplate = transactionManager?.let {
|
||||||
|
TransactionTemplate(it).also { template ->
|
||||||
|
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int {
|
override fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int {
|
||||||
if (records.isEmpty()) {
|
if (records.isEmpty()) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return records
|
val distinctRecords = records
|
||||||
.distinctBy { Triple(it.memberId, it.newsType, it.sourceKey) }
|
.distinctBy { Triple(it.memberId, it.newsType, it.sourceKey) }
|
||||||
.sumOf { record -> insertIgnore(record) }
|
|
||||||
|
return insertWithRetry(distinctRecords)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -33,30 +43,52 @@ class HomeFollowingNewsInboxPersistenceAdapter(
|
|||||||
return repository.findActiveFollowerIds(creatorId)
|
return repository.findActiveFollowerIds(creatorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun insertIgnore(record: HomeFollowingNewsInboxRecord): Int {
|
private fun insertWithRetry(records: List<HomeFollowingNewsInboxRecord>): Int {
|
||||||
val newsType = FollowingNewsType.valueOf(record.newsType)
|
var lastFailure: DataIntegrityViolationException? = null
|
||||||
if (repository.existsByMemberIdAndNewsTypeAndSourceKey(record.memberId, newsType, record.sourceKey)) {
|
repeat(MAX_INSERT_ATTEMPTS) {
|
||||||
|
try {
|
||||||
|
return executeInsertAttempt(records)
|
||||||
|
} catch (ex: DataIntegrityViolationException) {
|
||||||
|
lastFailure = ex
|
||||||
|
entityManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw requireNotNull(lastFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeInsertAttempt(records: List<HomeFollowingNewsInboxRecord>): Int {
|
||||||
|
return transactionTemplate?.execute { insertNewRows(records) } ?: insertNewRows(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertNewRows(records: List<HomeFollowingNewsInboxRecord>): Int {
|
||||||
|
val entities = records
|
||||||
|
.groupBy { SourceKey(newsType = it.newsType, sourceKey = it.sourceKey) }
|
||||||
|
.flatMap { (sourceKey, sourceRecords) ->
|
||||||
|
FollowingNewsType.valueOf(sourceKey.newsType)
|
||||||
|
val existingMemberIds = repository.findExistingMemberIds(
|
||||||
|
newsType = sourceKey.newsType,
|
||||||
|
sourceKey = sourceKey.sourceKey,
|
||||||
|
memberIds = sourceRecords.map { it.memberId }
|
||||||
|
).toSet()
|
||||||
|
sourceRecords
|
||||||
|
.filterNot { it.memberId in existingMemberIds }
|
||||||
|
.map { it.toEntity() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entities.isEmpty()) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
repository.saveAll(entities)
|
||||||
repository.saveAndFlush(record.toEntity(newsType))
|
repository.flush()
|
||||||
1
|
return entities.size
|
||||||
} catch (e: DataIntegrityViolationException) {
|
|
||||||
entityManager.clear()
|
|
||||||
if (repository.existsByMemberIdAndNewsTypeAndSourceKey(record.memberId, newsType, record.sourceKey)) {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun HomeFollowingNewsInboxRecord.toEntity(newsType: FollowingNewsType): HomeFollowingNewsInbox {
|
private fun HomeFollowingNewsInboxRecord.toEntity(): HomeFollowingNewsInbox {
|
||||||
return HomeFollowingNewsInbox(
|
return HomeFollowingNewsInbox(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
newsType = newsType,
|
newsType = FollowingNewsType.valueOf(newsType),
|
||||||
sourceKey = sourceKey,
|
sourceKey = sourceKey,
|
||||||
targetId = targetId,
|
targetId = targetId,
|
||||||
occurredAtUtc = occurredAtUtc,
|
occurredAtUtc = occurredAtUtc,
|
||||||
@@ -70,4 +102,13 @@ class HomeFollowingNewsInboxPersistenceAdapter(
|
|||||||
isAdult = isAdult
|
isAdult = isAdult
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class SourceKey(
|
||||||
|
val newsType: String,
|
||||||
|
val sourceKey: String
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_INSERT_ATTEMPTS = 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||||
|
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
class HomeFollowingNewsInboxPersistenceAdapterRetryTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("insertIgnoreAll은 JPA bulk insert unique 충돌 시 기존 row를 재조회하고 남은 row만 재시도한다")
|
||||||
|
fun shouldRetryRemainingRowsWhenBulkInsertConflictsWithExistingRow() {
|
||||||
|
val repository = Mockito.mock(HomeFollowingNewsInboxJpaRepository::class.java)
|
||||||
|
val entityManager = Mockito.mock(EntityManager::class.java)
|
||||||
|
val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
|
||||||
|
val sourceKey = "CREATOR_RANKING:1:2026-06-25"
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.findExistingMemberIds(
|
||||||
|
FollowingNewsType.CREATOR_RANKING.name,
|
||||||
|
sourceKey,
|
||||||
|
listOf(10L)
|
||||||
|
)
|
||||||
|
).thenReturn(emptyList()).thenReturn(listOf(10L))
|
||||||
|
Mockito.`when`(repository.saveAll(Mockito.anyList<HomeFollowingNewsInbox>()))
|
||||||
|
.thenThrow(DataIntegrityViolationException("duplicate"))
|
||||||
|
|
||||||
|
val insertedCount = adapter.insertIgnoreAll(
|
||||||
|
listOf(record(memberId = 10L, creatorId = 1L, sourceKey = sourceKey))
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0, insertedCount)
|
||||||
|
Mockito.verify(repository, Mockito.times(2)).findExistingMemberIds(
|
||||||
|
FollowingNewsType.CREATOR_RANKING.name,
|
||||||
|
sourceKey,
|
||||||
|
listOf(10L)
|
||||||
|
)
|
||||||
|
Mockito.verify(repository, Mockito.times(1)).saveAll(Mockito.anyList<HomeFollowingNewsInbox>())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun record(
|
||||||
|
memberId: Long,
|
||||||
|
creatorId: Long,
|
||||||
|
sourceKey: String
|
||||||
|
): HomeFollowingNewsInboxRecord {
|
||||||
|
return HomeFollowingNewsInboxRecord(
|
||||||
|
memberId = memberId,
|
||||||
|
creatorId = creatorId,
|
||||||
|
newsType = FollowingNewsType.CREATOR_RANKING.name,
|
||||||
|
sourceKey = sourceKey,
|
||||||
|
targetId = creatorId,
|
||||||
|
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 0, 0),
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
|
||||||
|
creatorNickname = "creator-$creatorId",
|
||||||
|
creatorProfileImagePath = "profile-$creatorId.png",
|
||||||
|
title = "title",
|
||||||
|
body = "body",
|
||||||
|
thumbnailImagePath = null,
|
||||||
|
rank = 1,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,17 @@ import kr.co.vividnext.sodalive.member.MemberRole
|
|||||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
|
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
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.mockito.Mockito
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.transaction.TestTransaction
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import javax.persistence.EntityManager
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@@ -48,26 +49,21 @@ class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("insertIgnoreAll은 exists 확인 이후 발생한 중복 insert 충돌도 예외 없이 무시한다")
|
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
|
||||||
fun shouldIgnoreDuplicateInsertRaceAfterExistsCheck() {
|
@DisplayName("실제 unique 중복 무시 이후 insertIgnoreAll을 호출한 트랜잭션은 커밋 가능하다")
|
||||||
val mockRepository = Mockito.mock(HomeFollowingNewsInboxJpaRepository::class.java)
|
fun shouldCommitTransactionAfterRealDuplicateCollisionIsIgnored() {
|
||||||
val mockEntityManager = Mockito.mock(EntityManager::class.java)
|
val sourceKey = "real-duplicate"
|
||||||
val raceAdapter = HomeFollowingNewsInboxPersistenceAdapter(mockRepository, mockEntityManager)
|
adapter.insertIgnoreAll(listOf(record(sourceKey = sourceKey)))
|
||||||
val record = record(sourceKey = "race-source-key")
|
entityManager.flush()
|
||||||
Mockito.`when`(
|
entityManager.clear()
|
||||||
mockRepository.existsByMemberIdAndNewsTypeAndSourceKey(
|
|
||||||
record.memberId,
|
|
||||||
FollowingNewsType.CREATOR_RANKING,
|
|
||||||
record.sourceKey
|
|
||||||
)
|
|
||||||
).thenReturn(false, true)
|
|
||||||
Mockito.`when`(mockRepository.saveAndFlush(Mockito.any(HomeFollowingNewsInbox::class.java)))
|
|
||||||
.thenThrow(DataIntegrityViolationException("duplicate"))
|
|
||||||
|
|
||||||
val insertCount = raceAdapter.insertIgnoreAll(listOf(record))
|
val insertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = sourceKey)))
|
||||||
|
val rows = repository.findAll()
|
||||||
|
|
||||||
assertEquals(0, insertCount)
|
assertEquals(0, insertCount)
|
||||||
Mockito.verify(mockEntityManager).clear()
|
assertEquals(1, rows.size)
|
||||||
|
TestTransaction.flagForCommit()
|
||||||
|
assertDoesNotThrow { TestTransaction.end() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user