test #449
@@ -7,8 +7,11 @@ import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
|||||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -22,8 +25,12 @@ import org.springframework.transaction.annotation.Propagation
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import org.springframework.transaction.event.TransactionPhase
|
import org.springframework.transaction.event.TransactionPhase
|
||||||
import org.springframework.transaction.event.TransactionalEventListener
|
import org.springframework.transaction.event.TransactionalEventListener
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
import org.springframework.util.LinkedMultiValueMap
|
import org.springframework.util.LinkedMultiValueMap
|
||||||
import org.springframework.web.client.RestTemplate
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
import javax.persistence.LockModeType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
||||||
@@ -37,13 +44,16 @@ enum class LanguageDetectTargetType {
|
|||||||
SERIES,
|
SERIES,
|
||||||
ORIGINAL_WORK,
|
ORIGINAL_WORK,
|
||||||
|
|
||||||
CREATOR_CONTENT_CATEGORY
|
CREATOR_CONTENT_CATEGORY,
|
||||||
|
CREATOR_COMMUNITY
|
||||||
}
|
}
|
||||||
|
|
||||||
class LanguageDetectEvent(
|
class LanguageDetectEvent(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val query: String,
|
val query: String,
|
||||||
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT
|
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT,
|
||||||
|
val sourceRevision: Long? = null,
|
||||||
|
val targetLanguage: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PapagoLanguageDetectResponse(
|
data class PapagoLanguageDetectResponse(
|
||||||
@@ -60,7 +70,10 @@ class LanguageDetectListener(
|
|||||||
private val seriesRepository: ContentSeriesRepository,
|
private val seriesRepository: ContentSeriesRepository,
|
||||||
private val originalWorkRepository: OriginalWorkRepository,
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
private val categoryRepository: CategoryRepository,
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||||
|
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@@ -95,6 +108,7 @@ class LanguageDetectListener(
|
|||||||
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
|
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
|
||||||
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
|
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
|
||||||
LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event)
|
LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.CREATOR_COMMUNITY -> handleCreatorCommunityLanguageDetect(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +380,53 @@ class LanguageDetectListener(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleCreatorCommunityLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val sourceRevision = event.sourceRevision ?: return
|
||||||
|
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(event.id) ?: return
|
||||||
|
if (
|
||||||
|
creatorCommunity.contentRevision != sourceRevision ||
|
||||||
|
creatorCommunity.content != event.query
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!creatorCommunity.languageCode.isNullOrBlank()) {
|
||||||
|
requestTranslationsAfterCommit(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val languageCode = detectLanguageCode(event, event.id) ?: return
|
||||||
|
if (languageCode !in getTranslatableLanguageCodes(null)) return
|
||||||
|
|
||||||
|
val currentCreatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(event.id) ?: return
|
||||||
|
entityManager.refresh(currentCreatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
if (
|
||||||
|
currentCreatorCommunity.contentRevision != sourceRevision ||
|
||||||
|
currentCreatorCommunity.content != event.query
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentCreatorCommunity.languageCode.isNullOrBlank()) {
|
||||||
|
requestTranslationsAfterCommit(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCreatorCommunity.languageCode = languageCode
|
||||||
|
creatorCommunityRepository.save(currentCreatorCommunity)
|
||||||
|
requestTranslationsAfterCommit(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestTranslationsAfterCommit(event: LanguageDetectEvent) {
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(
|
||||||
|
object : TransactionSynchronization {
|
||||||
|
override fun afterCommit() {
|
||||||
|
creatorCommunityTranslationService.requestTranslations(event.id, event.targetLanguage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
|
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
|
||||||
return languageDetectionCacheService.detectWithCache(event.query) {
|
return languageDetectionCacheService.detectWithCache(event.query) {
|
||||||
requestPapagoLanguageCode(event.query, targetIdForLog)
|
requestPapagoLanguageCode(event.query, targetIdForLog)
|
||||||
|
|||||||
+813
@@ -0,0 +1,813 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||||
|
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.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
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.TranslationMemory
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemoryRepository
|
||||||
|
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.member.block.BlockMemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
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.mockito.Mockito
|
||||||
|
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.boot.test.context.TestConfiguration
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.context.annotation.Primary
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
|
||||||
|
import org.springframework.test.util.AopTestUtils
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.Callable
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@DataJpaTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=" +
|
||||||
|
"jdbc:h2:mem:creator-community-language-detect;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,
|
||||||
|
LanguageDetectListener::class,
|
||||||
|
CreatorCommunityTranslationService::class,
|
||||||
|
TranslationSourceExtractor::class,
|
||||||
|
TranslationReadModelMaterializer::class,
|
||||||
|
ResourceTranslationJobScheduler::class,
|
||||||
|
TranslationJobScheduler::class,
|
||||||
|
CreatorCommunityLanguageDetectTest.AsyncConfiguration::class
|
||||||
|
)
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
class CreatorCommunityLanguageDetectTest @Autowired constructor(
|
||||||
|
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val translationJobRepository: TranslationJobRepository,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
private val languageDetectListener: LanguageDetectListener,
|
||||||
|
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||||
|
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||||
|
private val creatorCommunityService: CreatorCommunityService,
|
||||||
|
private val languageDetectionCacheService: ControlledLanguageDetectionCacheService,
|
||||||
|
private val translationMemoryRepository: TranslationMemoryRepository,
|
||||||
|
private val translationReadModelMaterializer: TranslationReadModelMaterializer,
|
||||||
|
private val detailPipelineTaskExecutor: DetailPipelineTaskExecutor,
|
||||||
|
transactionManager: PlatformTransactionManager
|
||||||
|
) {
|
||||||
|
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
languageDetectionCacheService.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 감지 이벤트는 기존 기본값을 유지하면서 현재 본문 메타데이터를 전달한다")
|
||||||
|
fun shouldKeepLegacyDefaultsAndDeclareCreatorCommunityMetadata() {
|
||||||
|
val fields = LanguageDetectEvent::class.java.declaredFields.map { it.name }
|
||||||
|
|
||||||
|
assertTrue(LanguageDetectTargetType.values().any { it.name == "CREATOR_COMMUNITY" })
|
||||||
|
assertTrue(fields.contains("sourceRevision"))
|
||||||
|
assertTrue(fields.contains("targetLanguage"))
|
||||||
|
assertEquals(LanguageDetectTargetType.CONTENT, LanguageDetectEvent(1L, "legacy").targetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("감지 후 번역 요청 서비스는 독립 프록시 트랜잭션을 사용한다")
|
||||||
|
fun shouldExposeProxiedRequiresNewTranslationRequestEntryPoint() {
|
||||||
|
val annotation = AopUtils.getTargetClass(creatorCommunityTranslationService)
|
||||||
|
.getMethod("requestTranslations", Long::class.javaPrimitiveType, String::class.java)
|
||||||
|
.getAnnotation(Transactional::class.java)
|
||||||
|
|
||||||
|
assertTrue(AopUtils.isAopProxy(creatorCommunityTranslationService))
|
||||||
|
assertEquals(Propagation.REQUIRES_NEW, annotation.propagation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("상세 파이프라인 executor는 이전 작업 완료로 다음 작업 latch를 해제하지 않는다")
|
||||||
|
fun shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned() {
|
||||||
|
val executor = DetailPipelineTaskExecutor()
|
||||||
|
val firstStarted = CountDownLatch(1)
|
||||||
|
val firstRelease = CountDownLatch(1)
|
||||||
|
val secondStarted = CountDownLatch(1)
|
||||||
|
val secondRelease = CountDownLatch(1)
|
||||||
|
executor.initialize()
|
||||||
|
|
||||||
|
try {
|
||||||
|
executor.prepareNextTask()
|
||||||
|
executor.execute {
|
||||||
|
firstStarted.countDown()
|
||||||
|
check(firstRelease.await(5, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
assertTrue(firstStarted.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
executor.prepareNextTask()
|
||||||
|
executor.execute {
|
||||||
|
secondStarted.countDown()
|
||||||
|
check(secondRelease.await(5, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
firstRelease.countDown()
|
||||||
|
assertTrue(secondStarted.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
assertEquals(false, executor.awaitTaskCompletion(0, TimeUnit.MILLISECONDS))
|
||||||
|
} finally {
|
||||||
|
firstRelease.countDown()
|
||||||
|
secondRelease.countDown()
|
||||||
|
executor.shutdown()
|
||||||
|
assertTrue(executor.threadPoolExecutor.awaitTermination(5, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커밋 후 감지는 요청 언어의 번역 작업을 실제로 저장한다")
|
||||||
|
fun shouldPersistRequestedTargetJobAfterCommittedDetection() {
|
||||||
|
val postId = persistPost(content = "current source", contentRevision = 3)
|
||||||
|
givenDetectedLanguage("current source", "ko")
|
||||||
|
|
||||||
|
publishAfterCommit(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = postId,
|
||||||
|
query = "current source",
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||||
|
sourceRevision = 3,
|
||||||
|
targetLanguage = "ja"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertJobTargets(postId, setOf("ja"))
|
||||||
|
assertPost(postId, content = "current source", languageCode = "ko")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("상세 조회는 실제 프록시 감지와 작업 저장 뒤 재료화한 요청 언어 번역문을 반환한다")
|
||||||
|
fun shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler() {
|
||||||
|
val sourceContent = "detail source content"
|
||||||
|
val postId = persistPost(content = sourceContent, contentRevision = 0, isCommentAvailable = false)
|
||||||
|
val blockingDetection = languageDetectionCacheService.block(sourceContent, "en")
|
||||||
|
detailPipelineTaskExecutor.prepareNextTask()
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertTrue(AopUtils.isAopProxy(creatorCommunityService))
|
||||||
|
assertTrue(AopUtils.isAopProxy(creatorCommunityTranslationService))
|
||||||
|
|
||||||
|
val initialDetail = creatorCommunityService.getCommunityPostDetail(
|
||||||
|
postId = postId,
|
||||||
|
memberId = 999L,
|
||||||
|
timezone = "Asia/Seoul",
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(sourceContent, initialDetail.content)
|
||||||
|
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||||
|
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||||
|
} finally {
|
||||||
|
blockingDetection.release.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(detailPipelineTaskExecutor.awaitTaskCompletion(5, TimeUnit.SECONDS))
|
||||||
|
assertPost(postId, content = sourceContent, languageCode = "en")
|
||||||
|
assertEquals(setOf("ko"), jobTargets(postId))
|
||||||
|
assertEquals(1, jobCount(postId))
|
||||||
|
|
||||||
|
val repeatedDetail = creatorCommunityService.getCommunityPostDetail(
|
||||||
|
postId = postId,
|
||||||
|
memberId = 999L,
|
||||||
|
timezone = "Asia/Seoul",
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
assertEquals(sourceContent, repeatedDetail.content)
|
||||||
|
|
||||||
|
val executor = Executors.newFixedThreadPool(2)
|
||||||
|
try {
|
||||||
|
val repeatedDetails = listOf(
|
||||||
|
executor.submit(
|
||||||
|
Callable {
|
||||||
|
creatorCommunityService.getCommunityPostDetail(postId, 999L, "Asia/Seoul", true).content
|
||||||
|
}
|
||||||
|
),
|
||||||
|
executor.submit(
|
||||||
|
Callable {
|
||||||
|
creatorCommunityService.getCommunityPostDetail(postId, 999L, "Asia/Seoul", true).content
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).map { it.get(5, TimeUnit.SECONDS) }
|
||||||
|
|
||||||
|
assertEquals(listOf(sourceContent, sourceContent), repeatedDetails)
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
assertEquals(1, jobCount(postId))
|
||||||
|
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
translationMemoryRepository.save(
|
||||||
|
TranslationMemory(
|
||||||
|
sourceHash = SourceTextNormalizer.hash(sourceContent),
|
||||||
|
sourceText = sourceContent,
|
||||||
|
sourceLanguage = "en",
|
||||||
|
targetLanguage = "ko",
|
||||||
|
translatedText = "translated detail content",
|
||||||
|
provider = TranslationReadModelMaterializer.DEFAULT_PROVIDER,
|
||||||
|
providerVersion = "nmt-v1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
translationReadModelMaterializer.materialize(
|
||||||
|
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||||
|
postId,
|
||||||
|
"ko"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedDetail = creatorCommunityService.getCommunityPostDetail(
|
||||||
|
postId = postId,
|
||||||
|
memberId = 999L,
|
||||||
|
timezone = "Asia/Seoul",
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("translated detail content", translatedDetail.content)
|
||||||
|
assertEquals(1, jobCount(postId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 범위 감지는 원문 언어를 제외한 실제 번역 작업을 저장한다")
|
||||||
|
fun shouldPersistAllNonSourceTargetJobsAfterCommittedDetection() {
|
||||||
|
val postId = persistPost(content = "English source", contentRevision = 1)
|
||||||
|
givenDetectedLanguage("English source", "en")
|
||||||
|
|
||||||
|
publishAfterCommit(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = postId,
|
||||||
|
query = "English source",
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||||
|
sourceRevision = 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertJobTargets(postId, setOf("ja", "ko"))
|
||||||
|
assertPost(postId, content = "English source", languageCode = "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("감지 중 변경된 본문은 refresh 뒤에도 이전 언어와 작업을 적용하지 않는다")
|
||||||
|
fun shouldRejectStalePostAfterBlockedDetection() {
|
||||||
|
val postId = persistPost(content = "before", contentRevision = 0)
|
||||||
|
val blockingDetection = languageDetectionCacheService.block("before", "ko")
|
||||||
|
val executor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val completion = executor.submit {
|
||||||
|
detectInTransaction(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = postId,
|
||||||
|
query = "before",
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||||
|
sourceRevision = 0,
|
||||||
|
targetLanguage = "ja"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||||
|
updatePost(postId, content = "after", contentRevision = 1)
|
||||||
|
blockingDetection.release.countDown()
|
||||||
|
completion.get(5, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
assertPost(postId, content = "after", languageCode = null)
|
||||||
|
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||||
|
} finally {
|
||||||
|
blockingDetection.release.countDown()
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("동시 감지 중 첫 감지가 언어를 저장해도 두 번째 요청 언어를 예약한다")
|
||||||
|
fun shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage() {
|
||||||
|
val postId = persistPost(content = "concurrent source", contentRevision = 0)
|
||||||
|
val concurrentDetection = languageDetectionCacheService.blockConcurrent("concurrent source", "ko")
|
||||||
|
val executor = Executors.newFixedThreadPool(2)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val first = executor.submit {
|
||||||
|
detectInTransaction(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = postId,
|
||||||
|
query = "concurrent source",
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||||
|
sourceRevision = 0,
|
||||||
|
targetLanguage = "ja"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(concurrentDetection.firstStarted.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
val second = executor.submit {
|
||||||
|
detectInTransaction(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = postId,
|
||||||
|
query = "concurrent source",
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||||
|
sourceRevision = 0,
|
||||||
|
targetLanguage = "en"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(concurrentDetection.secondStarted.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
concurrentDetection.firstRelease.countDown()
|
||||||
|
first.get(5, TimeUnit.SECONDS)
|
||||||
|
concurrentDetection.secondRelease.countDown()
|
||||||
|
second.get(5, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
assertPost(postId, content = "concurrent source", languageCode = "ko")
|
||||||
|
assertJobTargets(postId, setOf("en", "ja"))
|
||||||
|
} finally {
|
||||||
|
concurrentDetection.firstRelease.countDown()
|
||||||
|
concurrentDetection.secondRelease.countDown()
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("언어가 이미 확정된 동시 목표 이벤트는 둘 다 번역 작업으로 이어진다")
|
||||||
|
fun shouldContinueBothRequestedTargetsWhenLanguageIsAlreadyKnown() {
|
||||||
|
val postId = persistPost(content = "known source", contentRevision = 2, languageCode = "ko")
|
||||||
|
|
||||||
|
publishAfterCommit(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = postId,
|
||||||
|
query = "known source",
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||||
|
sourceRevision = 2,
|
||||||
|
targetLanguage = "ja"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
publishAfterCommit(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = postId,
|
||||||
|
query = "known source",
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||||
|
sourceRevision = 2,
|
||||||
|
targetLanguage = "en"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertJobTargets(postId, setOf("en", "ja"))
|
||||||
|
assertTrue(languageDetectionCacheService.queries.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("빈 본문과 미지원 감지값과 감지 실패는 원문과 작업 상태를 유지한다")
|
||||||
|
fun shouldKeepOriginalWhenDetectionCannotProduceSupportedLanguage() {
|
||||||
|
val blankPostId = persistPost(content = " ", contentRevision = 0)
|
||||||
|
val unsupportedPostId = persistPost(content = "unsupported", contentRevision = 0)
|
||||||
|
val failedPostId = persistPost(content = "provider failure", contentRevision = 0)
|
||||||
|
givenDetectedLanguage("unsupported", "fr")
|
||||||
|
|
||||||
|
publishAfterCommit(communityEvent(blankPostId, " ", 0))
|
||||||
|
publishAfterCommit(communityEvent(unsupportedPostId, "unsupported", 0))
|
||||||
|
publishAfterCommit(communityEvent(failedPostId, "provider failure", 0))
|
||||||
|
|
||||||
|
assertTrue(await { languageDetectionCacheService.queries.contains("unsupported") })
|
||||||
|
assertTrue(await { languageDetectionCacheService.queries.contains("provider failure") })
|
||||||
|
assertPost(blankPostId, content = " ", languageCode = null)
|
||||||
|
assertPost(unsupportedPostId, content = "unsupported", languageCode = null)
|
||||||
|
assertPost(failedPostId, content = "provider failure", languageCode = null)
|
||||||
|
assertEquals(emptySet<String>(), jobTargets(blankPostId))
|
||||||
|
assertEquals(emptySet<String>(), jobTargets(unsupportedPostId))
|
||||||
|
assertEquals(emptySet<String>(), jobTargets(failedPostId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 프록시 생성 트랜잭션이 롤백되면 게시글과 번역 후속 작업을 남기지 않는다")
|
||||||
|
fun shouldLeaveNoPostOrTranslationArtifactsWhenProxiedCreateRollsBack() {
|
||||||
|
val creator = persistCreator()
|
||||||
|
val content = "rolled back community ${UUID.randomUUID()}"
|
||||||
|
val postId = transactionTemplate.execute { status ->
|
||||||
|
creatorCommunityService.createCommunityPost(
|
||||||
|
audioFile = null,
|
||||||
|
postImage = null,
|
||||||
|
requestString = createRequest(content),
|
||||||
|
member = creator
|
||||||
|
)
|
||||||
|
val post = creatorCommunityRepository.findAll().single { it.content == content }
|
||||||
|
status.setRollbackOnly()
|
||||||
|
post.id!!
|
||||||
|
}!!
|
||||||
|
|
||||||
|
assertTrue(AopUtils.isAopProxy(creatorCommunityService))
|
||||||
|
assertTrue(transactionTemplate.execute { creatorCommunityRepository.findById(postId).isEmpty }!!)
|
||||||
|
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||||
|
assertTrue(
|
||||||
|
transactionTemplate.execute {
|
||||||
|
creatorCommunityTranslationRepository.findAll().none { it.creatorCommunityId == postId }
|
||||||
|
}!!
|
||||||
|
)
|
||||||
|
assertTrue(languageDetectionCacheService.queries.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 영문 본문 수정은 이전 번역을 숨기고 커밋 후 영문 감지와 새 작업으로 이어진다")
|
||||||
|
fun shouldHideStaleTranslationAndScheduleNewTargetsAfterCommittedEnglishModification() {
|
||||||
|
val creator = persistCreator()
|
||||||
|
val koreanContent = "기존 한국어 본문"
|
||||||
|
val englishContent = "current English community source"
|
||||||
|
val postId = transactionTemplate.execute {
|
||||||
|
val post = creatorCommunityRepository.save(
|
||||||
|
CreatorCommunity(
|
||||||
|
content = koreanContent,
|
||||||
|
price = 0,
|
||||||
|
isCommentAvailable = true,
|
||||||
|
isAdult = false,
|
||||||
|
languageCode = "ko",
|
||||||
|
contentRevision = 0
|
||||||
|
).apply {
|
||||||
|
member = creator
|
||||||
|
}
|
||||||
|
)
|
||||||
|
creatorCommunityTranslationRepository.save(
|
||||||
|
CreatorCommunityTranslation(
|
||||||
|
creatorCommunityId = post.id!!,
|
||||||
|
locale = "en",
|
||||||
|
content = "old English translation",
|
||||||
|
sourceRevision = 0,
|
||||||
|
sourceHash = SourceTextNormalizer.hash(koreanContent),
|
||||||
|
sourceLanguage = "ko"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
post.id!!
|
||||||
|
}!!
|
||||||
|
val blockingDetection = languageDetectionCacheService.block(englishContent, "en")
|
||||||
|
val executor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val modification = executor.submit {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
creatorCommunityService.modifyCommunityPost(
|
||||||
|
postImage = null,
|
||||||
|
requestString = modifyRequest(postId, englishContent),
|
||||||
|
member = creator
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
val post = creatorCommunityRepository.findById(postId).orElseThrow()
|
||||||
|
assertEquals(englishContent, post.content)
|
||||||
|
assertNull(post.languageCode)
|
||||||
|
assertEquals(1, post.contentRevision)
|
||||||
|
assertEquals(
|
||||||
|
englishContent,
|
||||||
|
creatorCommunityTranslationService.findDisplayContents(listOf(postId), "en")[postId]?.content
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"old English translation",
|
||||||
|
creatorCommunityTranslationRepository
|
||||||
|
.findByCreatorCommunityIdAndLocale(postId, "en")
|
||||||
|
?.content
|
||||||
|
)
|
||||||
|
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||||
|
}
|
||||||
|
|
||||||
|
blockingDetection.release.countDown()
|
||||||
|
modification.get(5, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
await {
|
||||||
|
transactionTemplate.execute {
|
||||||
|
creatorCommunityRepository.findById(postId).orElseThrow().languageCode == "en"
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertPost(postId, content = englishContent, languageCode = "en")
|
||||||
|
assertJobTargets(postId, setOf("ja", "ko"))
|
||||||
|
} finally {
|
||||||
|
blockingDetection.release.countDown()
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun communityEvent(postId: Long, content: String, sourceRevision: Long): LanguageDetectEvent {
|
||||||
|
return LanguageDetectEvent(
|
||||||
|
id = postId,
|
||||||
|
query = content,
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||||
|
sourceRevision = sourceRevision
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun givenDetectedLanguage(content: String, detectedLanguage: String) {
|
||||||
|
languageDetectionCacheService.detections[content] = detectedLanguage
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRequest(content: String): String {
|
||||||
|
return """{"content":"$content","price":0,"isCommentAvailable":true,"isAdult":false}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modifyRequest(postId: Long, content: String): String {
|
||||||
|
return """{"creatorCommunityId":$postId,"content":"$content"}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishAfterCommit(event: LanguageDetectEvent) {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
applicationEventPublisher.publishEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectInTransaction(event: LanguageDetectEvent) {
|
||||||
|
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
listener.detectLanguage(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistPost(
|
||||||
|
content: String,
|
||||||
|
contentRevision: Long,
|
||||||
|
languageCode: String? = null,
|
||||||
|
isCommentAvailable: Boolean = true
|
||||||
|
): Long {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val member = memberRepository.save(
|
||||||
|
Member(
|
||||||
|
password = "password",
|
||||||
|
nickname = "language-detect-${UUID.randomUUID()}",
|
||||||
|
role = MemberRole.CREATOR
|
||||||
|
)
|
||||||
|
)
|
||||||
|
creatorCommunityRepository.save(
|
||||||
|
CreatorCommunity(
|
||||||
|
content = content,
|
||||||
|
price = 0,
|
||||||
|
isCommentAvailable = isCommentAvailable,
|
||||||
|
isAdult = false,
|
||||||
|
languageCode = languageCode,
|
||||||
|
contentRevision = contentRevision
|
||||||
|
).apply {
|
||||||
|
this.member = member
|
||||||
|
}
|
||||||
|
).id!!
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistCreator(): Member {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
memberRepository.save(
|
||||||
|
Member(
|
||||||
|
password = "password",
|
||||||
|
nickname = "community-service-${UUID.randomUUID()}",
|
||||||
|
role = MemberRole.CREATOR
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePost(postId: Long, content: String, contentRevision: Long) {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
val post = creatorCommunityRepository.findById(postId).orElseThrow()
|
||||||
|
post.content = content
|
||||||
|
post.contentRevision = contentRevision
|
||||||
|
post.languageCode = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertPost(postId: Long, content: String, languageCode: String?) {
|
||||||
|
val post = transactionTemplate.execute {
|
||||||
|
creatorCommunityRepository.findById(postId).orElseThrow()
|
||||||
|
}!!
|
||||||
|
|
||||||
|
assertEquals(content, post.content)
|
||||||
|
if (languageCode == null) {
|
||||||
|
assertNull(post.languageCode)
|
||||||
|
} else {
|
||||||
|
assertEquals(languageCode, post.languageCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertJobTargets(postId: Long, expectedTargets: Set<String>) {
|
||||||
|
assertTrue(await { jobTargets(postId) == expectedTargets })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jobTargets(postId: Long): Set<String> {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
translationJobRepository.findAll()
|
||||||
|
.filter {
|
||||||
|
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && it.resourceId == postId
|
||||||
|
}
|
||||||
|
.map { it.targetLanguage }
|
||||||
|
.toSet()
|
||||||
|
}.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jobCount(postId: Long): Int {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
translationJobRepository.findAll().count {
|
||||||
|
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && it.resourceId == postId
|
||||||
|
}
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun await(condition: () -> Boolean): Boolean {
|
||||||
|
repeat(50) {
|
||||||
|
if (condition()) return true
|
||||||
|
Thread.sleep(100)
|
||||||
|
}
|
||||||
|
return condition()
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
@EnableAsync
|
||||||
|
class AsyncConfiguration {
|
||||||
|
@Bean(name = ["taskExecutor"])
|
||||||
|
fun taskExecutor(): DetailPipelineTaskExecutor {
|
||||||
|
return DetailPipelineTaskExecutor()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
fun languageDetectionCacheService(
|
||||||
|
languageDetectionResultRepository: LanguageDetectionResultRepository
|
||||||
|
): ControlledLanguageDetectionCacheService {
|
||||||
|
return ControlledLanguageDetectionCacheService(languageDetectionResultRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun creatorCommunityService(
|
||||||
|
repository: CreatorCommunityRepository,
|
||||||
|
blockMemberRepository: BlockMemberRepository,
|
||||||
|
likeRepository: CreatorCommunityLikeRepository,
|
||||||
|
commentRepository: CreatorCommunityCommentRepository,
|
||||||
|
useCanRepository: UseCanRepository,
|
||||||
|
applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||||
|
entityManager: EntityManager
|
||||||
|
): CreatorCommunityService {
|
||||||
|
return CreatorCommunityService(
|
||||||
|
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||||
|
repository = repository,
|
||||||
|
blockMemberRepository = blockMemberRepository,
|
||||||
|
likeRepository = likeRepository,
|
||||||
|
commentRepository = commentRepository,
|
||||||
|
useCanRepository = useCanRepository,
|
||||||
|
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||||
|
objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
|
||||||
|
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||||
|
applicationEventPublisher = applicationEventPublisher,
|
||||||
|
messageSource = SodaMessageSource(),
|
||||||
|
langContext = LangContext(),
|
||||||
|
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java),
|
||||||
|
creatorCommunityTranslationService = creatorCommunityTranslationService,
|
||||||
|
entityManager = entityManager,
|
||||||
|
imageBucket = "test-image-bucket",
|
||||||
|
contentBucket = "test-content-bucket",
|
||||||
|
imageHost = "https://test.cloudfront.net"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ControlledLanguageDetectionCacheService(
|
||||||
|
languageDetectionResultRepository: LanguageDetectionResultRepository
|
||||||
|
) : LanguageDetectionCacheService(languageDetectionResultRepository) {
|
||||||
|
val detections = ConcurrentHashMap<String, String>()
|
||||||
|
val queries = ConcurrentLinkedQueue<String>()
|
||||||
|
private val blockingDetections = ConcurrentHashMap<String, BlockingDetection>()
|
||||||
|
private val concurrentDetections = ConcurrentHashMap<String, ConcurrentDetection>()
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
detections.clear()
|
||||||
|
queries.clear()
|
||||||
|
blockingDetections.clear()
|
||||||
|
concurrentDetections.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun block(query: String, detectedLanguage: String): BlockingDetection {
|
||||||
|
return BlockingDetection(detectedLanguage).also { blockingDetections[query] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun blockConcurrent(query: String, detectedLanguage: String): ConcurrentDetection {
|
||||||
|
return ConcurrentDetection(detectedLanguage).also { concurrentDetections[query] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun detectWithCache(query: String, provider: String, detector: () -> String?): String? {
|
||||||
|
queries.add(query)
|
||||||
|
val concurrentDetection = concurrentDetections[query]
|
||||||
|
if (concurrentDetection != null) {
|
||||||
|
return concurrentDetection.detect()
|
||||||
|
}
|
||||||
|
val blockingDetection = blockingDetections[query]
|
||||||
|
if (blockingDetection != null) {
|
||||||
|
blockingDetection.started.countDown()
|
||||||
|
check(blockingDetection.release.await(5, TimeUnit.SECONDS))
|
||||||
|
return blockingDetection.detectedLanguage
|
||||||
|
}
|
||||||
|
return detections[query]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockingDetection(val detectedLanguage: String) {
|
||||||
|
val started = CountDownLatch(1)
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConcurrentDetection(private val detectedLanguage: String) {
|
||||||
|
private val order = AtomicInteger()
|
||||||
|
val firstStarted = CountDownLatch(1)
|
||||||
|
val secondStarted = CountDownLatch(1)
|
||||||
|
val firstRelease = CountDownLatch(1)
|
||||||
|
val secondRelease = CountDownLatch(1)
|
||||||
|
|
||||||
|
fun detect(): String {
|
||||||
|
when (order.incrementAndGet()) {
|
||||||
|
1 -> {
|
||||||
|
firstStarted.countDown()
|
||||||
|
check(firstRelease.await(5, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> {
|
||||||
|
secondStarted.countDown()
|
||||||
|
check(secondRelease.await(5, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detectedLanguage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DetailPipelineTaskExecutor : ThreadPoolTaskExecutor() {
|
||||||
|
private val completion = AtomicReference(CountDownLatch(0))
|
||||||
|
|
||||||
|
init {
|
||||||
|
corePoolSize = 1
|
||||||
|
maxPoolSize = 1
|
||||||
|
setThreadNamePrefix("detail-pipeline-")
|
||||||
|
setTaskDecorator { task ->
|
||||||
|
val taskCompletion = completion.get()
|
||||||
|
Runnable {
|
||||||
|
try {
|
||||||
|
task.run()
|
||||||
|
} finally {
|
||||||
|
taskCompletion.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prepareNextTask() {
|
||||||
|
completion.set(CountDownLatch(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun awaitTaskCompletion(timeout: Long, unit: TimeUnit): Boolean {
|
||||||
|
return completion.get().await(timeout, unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user