fix(language-detection): 최초 감지 캐시 경합을 직렬화한다
This commit is contained in:
+336
@@ -0,0 +1,336 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.aop.support.AopUtils
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.ClientHttpRequest
|
||||
import org.springframework.http.client.ClientHttpRequestFactory
|
||||
import org.springframework.http.client.ClientHttpResponse
|
||||
import org.springframework.test.util.AopTestUtils
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.URI
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutorCompletionService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:creator-community-language-detection-cache-concurrency;" +
|
||||
"MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"cloud.naver.papago-client-id=test-client-id",
|
||||
"cloud.naver.papago-client-secret=test-client-secret"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(
|
||||
QueryDslConfig::class,
|
||||
AudioContentThemeQueryRepository::class,
|
||||
LanguageDetectionCacheService::class,
|
||||
LanguageDetectListener::class,
|
||||
CreatorCommunityTranslationService::class,
|
||||
TranslationSourceExtractor::class,
|
||||
TranslationReadModelMaterializer::class,
|
||||
ResourceTranslationJobScheduler::class,
|
||||
TranslationJobScheduler::class
|
||||
)
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired constructor(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val languageDetectionResultRepository: LanguageDetectionResultRepository,
|
||||
private val translationJobRepository: TranslationJobRepository,
|
||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||
private val languageDetectListener: LanguageDetectListener,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||
propagationBehavior = Propagation.REQUIRES_NEW.value()
|
||||
}
|
||||
private lateinit var papagoRequests: ControlledPapagoRequestFactory
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
papagoRequests = ControlledPapagoRequestFactory()
|
||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||
ReflectionTestUtils.setField(listener, "restTemplate", RestTemplate(papagoRequests))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최초 감지 캐시 저장 경합에서도 두 요청 언어의 번역 작업을 보존한다")
|
||||
fun shouldPreserveBothTargetsWhenInitialDetectionCacheWritesRace() {
|
||||
val content = "initial detection cache race ${UUID.randomUUID()}"
|
||||
val contentRevision = 7L
|
||||
val postId = persistPost(content, contentRevision)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val completions = ExecutorCompletionService<Unit>(executor)
|
||||
|
||||
try {
|
||||
assertTrue(AopUtils.isAopProxy(languageDetectionCacheService))
|
||||
completions.submit(detectionTask(postId, content, contentRevision, "ja"))
|
||||
completions.submit(detectionTask(postId, content, contentRevision, "en"))
|
||||
|
||||
assertTrue(
|
||||
papagoRequests.firstProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"first cache miss did not reach the provider boundary"
|
||||
)
|
||||
assertTrue(
|
||||
papagoRequests.secondProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"second cache miss did not reach the provider boundary"
|
||||
)
|
||||
|
||||
papagoRequests.firstProviderRelease.countDown()
|
||||
val firstCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("first detection transaction did not complete")
|
||||
firstCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
papagoRequests.secondProviderRelease.countDown()
|
||||
val secondCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("second detection transaction did not complete")
|
||||
secondCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertCacheRow(content)
|
||||
assertPostAndJobs(postId, content, contentRevision)
|
||||
} finally {
|
||||
papagoRequests.releaseAll()
|
||||
executor.shutdownNow()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("서로 다른 게시물의 동일 본문 최초 감지 경합에서도 각각의 전체 번역 작업을 보존한다")
|
||||
fun shouldPreserveAllNonSourceTargetsForDifferentPostsWithSameContentDuringInitialDetectionRace() {
|
||||
val content = "shared initial detection cache race ${UUID.randomUUID()}"
|
||||
val contentRevision = 7L
|
||||
val firstPostId = persistPost(content, contentRevision)
|
||||
val secondPostId = persistPost(content, contentRevision)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val completions = ExecutorCompletionService<Unit>(executor)
|
||||
|
||||
try {
|
||||
assertTrue(AopUtils.isAopProxy(languageDetectionCacheService))
|
||||
completions.submit(detectionTask(firstPostId, content, contentRevision, null))
|
||||
completions.submit(detectionTask(secondPostId, content, contentRevision, null))
|
||||
|
||||
assertTrue(
|
||||
papagoRequests.firstProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"first cache miss did not reach the provider boundary"
|
||||
)
|
||||
assertTrue(
|
||||
papagoRequests.secondProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"second cache miss did not reach the provider boundary"
|
||||
)
|
||||
|
||||
papagoRequests.firstProviderRelease.countDown()
|
||||
val firstCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("first detection transaction did not complete")
|
||||
firstCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
papagoRequests.secondProviderRelease.countDown()
|
||||
val secondCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("second detection transaction did not complete")
|
||||
secondCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertCacheRow(content)
|
||||
assertPostAndJobs(firstPostId, content, contentRevision)
|
||||
assertPostAndJobs(secondPostId, content, contentRevision)
|
||||
} finally {
|
||||
papagoRequests.releaseAll()
|
||||
executor.shutdownNow()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectionTask(
|
||||
postId: Long,
|
||||
content: String,
|
||||
contentRevision: Long,
|
||||
targetLanguage: String?
|
||||
): Callable<Unit> {
|
||||
return Callable {
|
||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
listener.detectLanguage(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = contentRevision,
|
||||
targetLanguage = targetLanguage
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistPost(content: String, contentRevision: Long): Long {
|
||||
return requiresNewTransactionTemplate.execute {
|
||||
val member = memberRepository.save(
|
||||
Member(
|
||||
password = "password",
|
||||
nickname = "cache-race-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = null,
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
this.member = member
|
||||
}
|
||||
).id!!
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun assertCacheRow(content: String) {
|
||||
val rows = requiresNewTransactionTemplate.execute {
|
||||
languageDetectionResultRepository.findAll().filter {
|
||||
it.sourceHash == SourceTextNormalizer.hash(content) &&
|
||||
it.provider == LanguageDetectionCacheService.DEFAULT_PROVIDER &&
|
||||
it.normalizationVersion == SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("ko", rows.single().detectedLanguage)
|
||||
}
|
||||
|
||||
private fun assertPostAndJobs(postId: Long, content: String, contentRevision: Long) {
|
||||
val post = requiresNewTransactionTemplate.execute {
|
||||
creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
}!!
|
||||
val jobs = requiresNewTransactionTemplate.execute {
|
||||
translationJobRepository.findAll().filter {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY &&
|
||||
it.resourceId == postId &&
|
||||
it.fieldKey == "content"
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
assertEquals(content, post.content)
|
||||
assertEquals(contentRevision, post.contentRevision)
|
||||
assertEquals("ko", post.languageCode)
|
||||
assertEquals(2, jobs.size)
|
||||
assertEquals(setOf("ja", "en"), jobs.map { it.targetLanguage }.toSet())
|
||||
assertTrue(
|
||||
jobs.all { job ->
|
||||
job.sourceHash == SourceTextNormalizer.hash(content) &&
|
||||
job.sourceLanguage == "ko"
|
||||
}
|
||||
)
|
||||
assertTrue(jobs.all { it.status == TranslationJobStatus.PENDING })
|
||||
}
|
||||
|
||||
private class ControlledPapagoRequestFactory : ClientHttpRequestFactory {
|
||||
private val requestOrder = AtomicInteger()
|
||||
val firstProviderEntry = CountDownLatch(1)
|
||||
val secondProviderEntry = CountDownLatch(1)
|
||||
val firstProviderRelease = CountDownLatch(1)
|
||||
val secondProviderRelease = CountDownLatch(1)
|
||||
|
||||
override fun createRequest(uri: URI, httpMethod: HttpMethod): ClientHttpRequest {
|
||||
return object : ClientHttpRequest {
|
||||
private val headers = HttpHeaders()
|
||||
private val body = ByteArrayOutputStream()
|
||||
|
||||
override fun getMethodValue(): String = httpMethod.name
|
||||
|
||||
override fun getURI(): URI = uri
|
||||
|
||||
override fun getHeaders(): HttpHeaders = headers
|
||||
|
||||
override fun getBody(): OutputStream = body
|
||||
|
||||
override fun execute(): ClientHttpResponse {
|
||||
val languageCode = when (requestOrder.incrementAndGet()) {
|
||||
1 -> {
|
||||
firstProviderEntry.countDown()
|
||||
firstProviderRelease.await()
|
||||
"ko"
|
||||
}
|
||||
|
||||
2 -> {
|
||||
secondProviderEntry.countDown()
|
||||
secondProviderRelease.await()
|
||||
"en"
|
||||
}
|
||||
|
||||
else -> error("unexpected provider request")
|
||||
}
|
||||
return languageDetectionResponse(languageCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
firstProviderRelease.countDown()
|
||||
secondProviderRelease.countDown()
|
||||
}
|
||||
|
||||
private fun languageDetectionResponse(languageCode: String): ClientHttpResponse {
|
||||
return object : ClientHttpResponse {
|
||||
private val headers = HttpHeaders().apply {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
}
|
||||
|
||||
override fun getStatusCode(): HttpStatus = HttpStatus.OK
|
||||
|
||||
override fun getRawStatusCode(): Int = HttpStatus.OK.value()
|
||||
|
||||
override fun getStatusText(): String = "OK"
|
||||
|
||||
override fun getHeaders(): HttpHeaders = headers
|
||||
|
||||
override fun getBody() = ByteArrayInputStream("{\"langCode\":\"$languageCode\"}".toByteArray())
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-1
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
@@ -37,6 +38,67 @@ class LanguageDetectionCacheServiceTest {
|
||||
|
||||
assertEquals("en", detected)
|
||||
assertEquals(0, providerCalls)
|
||||
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LanguageDetectionResult::class.java))
|
||||
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verifyNoMoreInteractions(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최초 캐시 저장 경합에서는 저장된 승자의 감지 언어를 반환한다")
|
||||
fun shouldReturnStoredWinnerAfterInitialCacheInsert() {
|
||||
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
|
||||
val service = LanguageDetectionCacheService(repository)
|
||||
val sourceHash = SourceTextNormalizer.hash("Hello world")
|
||||
|
||||
Mockito.`when`(
|
||||
repository.findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(null)
|
||||
Mockito.`when`(
|
||||
repository.insertIfAbsent(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = "Hello world",
|
||||
detectedLanguage = "en",
|
||||
provider = "papago",
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(0)
|
||||
Mockito.`when`(
|
||||
repository.findDetectedLanguageForUpdate(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn("ko")
|
||||
|
||||
val detected = service.detectWithCache("Hello world") { "en" }
|
||||
|
||||
assertEquals("ko", detected)
|
||||
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verify(repository).insertIfAbsent(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = "Hello world",
|
||||
detectedLanguage = "en",
|
||||
provider = "papago",
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verify(repository).findDetectedLanguageForUpdate(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verifyNoMoreInteractions(repository)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user