diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt index f3a67654..d1048470 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt @@ -25,18 +25,22 @@ class LanguageDetectionCacheService( ) if (cached != null) return cached.detectedLanguage - val detectedLanguage = detector()?.takeIf { it.isNotBlank() } ?: return null - languageDetectionResultRepository.save( - LanguageDetectionResult( + val detectedLanguage = detector()?.takeIf { it.isNotBlank() }?.lowercase() ?: return null + languageDetectionResultRepository.insertIfAbsent( + sourceHash = sourceHash, + sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH), + detectedLanguage = detectedLanguage, + provider = provider, + confidence = null, + normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION + ) + return requireNotNull( + languageDetectionResultRepository.findDetectedLanguageForUpdate( sourceHash = sourceHash, - sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH), - detectedLanguage = detectedLanguage.lowercase(), provider = provider, - confidence = null, normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION ) ) - return detectedLanguage.lowercase() } companion object { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt index 23e4ae35..be8c003a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.content import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface LanguageDetectionResultRepository : JpaRepository { fun findBySourceHashAndProviderAndNormalizationVersion( @@ -8,4 +11,55 @@ interface LanguageDetectionResultRepository : JpaRepository(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(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 { + 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 + } + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt index ab2aefda..9125bb91 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt @@ -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) } }