feat(i18n): 번역 작업 큐와 언어 감지 캐시를 도입한다
조회 중 외부 번역 호출을 줄이고 누락 번역을 비동기 job으로 처리한다.
This commit is contained in:
@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR
|
||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -26,7 +26,7 @@ class ChatCharacterControllerTest {
|
||||
private val chatRoomService = Mockito.mock(ChatRoomService::class.java)
|
||||
private val characterCommentService = Mockito.mock(CharacterCommentService::class.java)
|
||||
private val curationQueryService = Mockito.mock(CharacterCurationQueryService::class.java)
|
||||
private val translationService = Mockito.mock(PapagoTranslationService::class.java)
|
||||
private val resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
private val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
|
||||
private val langContext = LangContext().apply { setLang(Lang.JA) }
|
||||
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
@@ -36,7 +36,7 @@ class ChatCharacterControllerTest {
|
||||
chatRoomService = chatRoomService,
|
||||
characterCommentService = characterCommentService,
|
||||
curationQueryService = curationQueryService,
|
||||
translationService = translationService,
|
||||
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
|
||||
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
|
||||
langContext = langContext,
|
||||
memberContentPreferenceService = memberContentPreferenceService,
|
||||
@@ -73,7 +73,7 @@ class ChatCharacterControllerTest {
|
||||
chatRoomService = chatRoomService,
|
||||
characterCommentService = characterCommentService,
|
||||
curationQueryService = curationQueryService,
|
||||
translationService = translationService,
|
||||
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
|
||||
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
|
||||
langContext = LangContext().apply { setLang(Lang.EN) },
|
||||
memberContentPreferenceService = memberContentPreferenceService,
|
||||
|
||||
@@ -18,7 +18,7 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -44,7 +44,7 @@ class AudioContentServiceTest {
|
||||
private lateinit var commentRepository: AudioContentCommentRepository
|
||||
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
|
||||
private lateinit var pinContentRepository: PinContentRepository
|
||||
private lateinit var translationService: PapagoTranslationService
|
||||
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||
private lateinit var contentTranslationRepository: ContentTranslationRepository
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||
@@ -66,7 +66,7 @@ class AudioContentServiceTest {
|
||||
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
|
||||
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
|
||||
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
|
||||
translationService = Mockito.mock(PapagoTranslationService::class.java)
|
||||
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||
@@ -85,7 +85,7 @@ class AudioContentServiceTest {
|
||||
commentRepository = commentRepository,
|
||||
audioContentLikeRepository = audioContentLikeRepository,
|
||||
pinContentRepository = pinContentRepository,
|
||||
translationService = translationService,
|
||||
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
|
||||
contentTranslationRepository = contentTranslationRepository,
|
||||
s3Uploader = s3Uploader,
|
||||
objectMapper = ObjectMapper(),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class LanguageDetectionCacheServiceTest {
|
||||
@Test
|
||||
fun shouldReuseCachedLanguageDetectionForSameNormalizedText() {
|
||||
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(
|
||||
LanguageDetectionResult(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = "Hello world",
|
||||
detectedLanguage = "en",
|
||||
provider = "papago",
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
)
|
||||
|
||||
var providerCalls = 0
|
||||
val detected = service.detectWithCache("Hello world") {
|
||||
providerCalls++
|
||||
"ko"
|
||||
}
|
||||
|
||||
assertEquals("en", detected)
|
||||
assertEquals(0, providerCalls)
|
||||
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LanguageDetectionResult::class.java))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SourceTextNormalizerTest {
|
||||
@Test
|
||||
fun shouldNormalizeWhitespaceAndUnicodeBeforeHashing() {
|
||||
val composed = "카페\n\t소개"
|
||||
val decomposed = "카페 소개"
|
||||
|
||||
assertEquals("카페 소개", SourceTextNormalizer.normalize(composed))
|
||||
assertEquals(SourceTextNormalizer.hash(composed), SourceTextNormalizer.hash(decomposed))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
|
||||
class TranslationJobSchedulerTest {
|
||||
@Test
|
||||
fun shouldCreateOnePendingJobForMissingNormalizedText() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val scheduler = TranslationJobScheduler(jobRepository)
|
||||
|
||||
Mockito.`when`(
|
||||
jobRepository.findActiveJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
targetLanguage = "en",
|
||||
sourceHash = SourceTextNormalizer.hash("제목")
|
||||
)
|
||||
).thenReturn(null)
|
||||
|
||||
scheduler.scheduleMissingTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceText = " 제목\n",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "EN"
|
||||
)
|
||||
|
||||
val captor = ArgumentCaptor.forClass(TranslationJob::class.java)
|
||||
Mockito.verify(jobRepository).save(captor.capture())
|
||||
|
||||
val saved = captor.value
|
||||
assertEquals(LanguageTranslationTargetType.CONTENT, saved.resourceType)
|
||||
assertEquals(10L, saved.resourceId)
|
||||
assertEquals("title", saved.fieldKey)
|
||||
assertEquals("제목", saved.sourceText)
|
||||
assertEquals(SourceTextNormalizer.hash("제목"), saved.sourceHash)
|
||||
assertEquals("ko", saved.sourceLanguage)
|
||||
assertEquals("en", saved.targetLanguage)
|
||||
assertEquals(TranslationJobStatus.PENDING, saved.status)
|
||||
assertNotNull(saved.nextRetryAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNotCreateDuplicateJobWhenSameCompletedJobAlreadyExists() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val scheduler = TranslationJobScheduler(jobRepository)
|
||||
val sourceHash = SourceTextNormalizer.hash("제목")
|
||||
|
||||
Mockito.`when`(
|
||||
jobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
targetLanguage = "en",
|
||||
sourceHash = sourceHash
|
||||
)
|
||||
).thenReturn(
|
||||
TranslationJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceHash = sourceHash,
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en",
|
||||
status = TranslationJobStatus.COMPLETED
|
||||
)
|
||||
)
|
||||
|
||||
scheduler.scheduleMissingTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en"
|
||||
)
|
||||
|
||||
Mockito.verify(jobRepository, Mockito.never()).save(Mockito.any(TranslationJob::class.java))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
class TranslationJobWorkerTest {
|
||||
@Test
|
||||
fun shouldRunEveryTenMinutesByDefault() {
|
||||
val scheduled = TranslationJobWorker::class.java
|
||||
.getDeclaredMethod("runPendingJobs")
|
||||
.getAnnotation(Scheduled::class.java)
|
||||
|
||||
assertEquals("\${sodalive.translation-job.fixed-delay-ms:600000}", scheduled.fixedDelayString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldClaimPendingJobByLockedRepositoryMethod() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = successfulProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val job = translationJob()
|
||||
job.id = 100L
|
||||
|
||||
Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(100L)
|
||||
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job))
|
||||
|
||||
worker.processNextJob()
|
||||
|
||||
Mockito.verify(jobRepository).findNextPendingJobIdForUpdate(anyLocalDateTime())
|
||||
Mockito.verify(jobRepository, Mockito.never())
|
||||
.findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc(
|
||||
anyTranslationJobStatus(),
|
||||
anyLocalDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldRetryFailedJobWithBackoff() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = failingProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val job = translationJob()
|
||||
job.id = 200L
|
||||
val beforeRetryAt = job.nextRetryAt
|
||||
|
||||
Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(200L)
|
||||
Mockito.`when`(jobRepository.findById(200L)).thenReturn(Optional.of(job))
|
||||
|
||||
worker.processNextJob()
|
||||
|
||||
assertEquals(TranslationJobStatus.PENDING, job.status)
|
||||
assertEquals(1, job.retryCount)
|
||||
assertEquals("provider down", job.lastErrorMessage)
|
||||
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
|
||||
}
|
||||
|
||||
private fun translationJob(): TranslationJob {
|
||||
return TranslationJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceHash = SourceTextNormalizer.hash("제목"),
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en"
|
||||
)
|
||||
}
|
||||
|
||||
private fun successfulProvider(): TranslationProvider {
|
||||
return object : TranslationProvider {
|
||||
override val providerName: String = "papago"
|
||||
override val providerVersion: String = "nmt-v1"
|
||||
|
||||
override fun translate(request: TranslateRequest): TranslateResult {
|
||||
return TranslateResult(listOf("title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun failingProvider(): TranslationProvider {
|
||||
return object : TranslationProvider {
|
||||
override val providerName: String = "papago"
|
||||
override val providerVersion: String = "nmt-v1"
|
||||
|
||||
override fun translate(request: TranslateRequest): TranslateResult {
|
||||
throw IllegalStateException("provider down")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun anyLocalDateTime(): LocalDateTime {
|
||||
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||
}
|
||||
|
||||
private fun anyTranslationJobStatus(): TranslationJobStatus {
|
||||
return Mockito.any(TranslationJobStatus::class.java) ?: TranslationJobStatus.PENDING
|
||||
}
|
||||
}
|
||||
|
||||
private class TestTransactionManager : AbstractPlatformTransactionManager() {
|
||||
override fun doGetTransaction(): Any {
|
||||
return Any()
|
||||
}
|
||||
|
||||
override fun doBegin(transaction: Any, definition: org.springframework.transaction.TransactionDefinition) {
|
||||
}
|
||||
|
||||
override fun doCommit(status: DefaultTransactionStatus) {
|
||||
}
|
||||
|
||||
override fun doRollback(status: DefaultTransactionStatus) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user