Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.tripsync.domain.repository.RoomMemberProfileRepository
import com.tripsync.domain.repository.SatisfactionScoreRepository
import com.tripsync.domain.repository.ScheduleRepository
import com.tripsync.domain.repository.ScheduleSlotRepository
import com.tripsync.domain.repository.TripRoomRepository
import com.tripsync.domain.repository.UserRepository
import com.tripsync.web.dto.GenerateScheduleDto
import org.springframework.http.HttpStatus
Expand All @@ -35,6 +36,7 @@ class ScheduleGenerationPersistenceService(
private val placeQueryRepository: PlaceQueryRepository,
private val externalPopularityMetricRepository: ExternalPopularityMetricRepository,
private val userRepository: UserRepository,
private val tripRoomRepository: TripRoomRepository,
private val accessPolicy: ScheduleAccessPolicy,
) {
@Transactional(readOnly = true)
Expand Down Expand Up @@ -100,7 +102,8 @@ class ScheduleGenerationPersistenceService(
options: List<ScheduleOptionDraft>,
personaValidationByType: Map<ScheduleOptionType, Map<String, Any>>,
): SavedScheduleGeneration {
val room = accessPolicy.getActiveRoom(roomId)
val room = tripRoomRepository.findActiveByIdForUpdate(roomId, YnFlag.N)
?: throw DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.")
val version = (scheduleRepository.findTopByRoomIdAndDelYnOrderByVersionDesc(room.id, YnFlag.N)?.version ?: 0) + 1
val saved = options.map { option ->
val personaValidation = personaValidationByType[option.optionType]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,29 @@ package com.tripsync.domain.repository

import com.tripsync.domain.entity.TripRoom
import com.tripsync.domain.enums.YnFlag
import jakarta.persistence.LockModeType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository

@Repository
interface TripRoomRepository : JpaRepository<TripRoom, Long> {
fun findByShareCode(shareCode: String): TripRoom?
fun findByShareCodeAndDelYn(shareCode: String, delYn: YnFlag): TripRoom?

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"""
select room
from TripRoom room
where room.id = :roomId
and room.delYn = :delYn
"""
)
fun findActiveByIdForUpdate(
@Param("roomId") roomId: Long,
@Param("delYn") delYn: YnFlag,
): TripRoom?
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.tripsync.application.schedule

import com.tripsync.application.consensus.ScheduleOptionDraft
import com.tripsync.application.consensus.ScheduleSlotDraft
import com.tripsync.application.consensus.SatisfactionDraft
import com.tripsync.common.exception.DomainException
import com.tripsync.domain.entity.Place
import com.tripsync.domain.entity.RoomMember
Expand All @@ -11,6 +14,7 @@ import com.tripsync.domain.enums.AuthProvider
import com.tripsync.domain.enums.ReasonAxis
import com.tripsync.domain.enums.RoomMemberRole
import com.tripsync.domain.enums.ScheduleOptionType
import com.tripsync.domain.enums.ScoreAxis
import com.tripsync.domain.enums.SlotType
import com.tripsync.domain.enums.TripRoomStatus
import com.tripsync.domain.repository.PlaceRepository
Expand All @@ -19,6 +23,7 @@ import com.tripsync.domain.repository.ScheduleRepository
import com.tripsync.domain.repository.ScheduleSlotRepository
import com.tripsync.domain.repository.TripRoomRepository
import com.tripsync.domain.repository.UserRepository
import com.tripsync.web.dto.GenerateScheduleDto
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
Expand All @@ -31,11 +36,15 @@ import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit

@SpringBootTest
@ActiveProfiles("test")
class ScheduleServiceTest(
@Autowired private val scheduleService: ScheduleService,
@Autowired private val generationPersistenceService: ScheduleGenerationPersistenceService,
@Autowired private val userRepository: UserRepository,
@Autowired private val tripRoomRepository: TripRoomRepository,
@Autowired private val roomMemberRepository: RoomMemberRepository,
Expand Down Expand Up @@ -125,6 +134,51 @@ class ScheduleServiceTest(
assertEquals("INVALID_REQUEST", error.code)
}

@Test
fun `concurrent generated schedule saves allocate different versions`() {
val fixture = createFixture(isConfirmed = false)
val workers = 4
val dto = GenerateScheduleDto(
destination = "충남",
tripDate = "2026-06-01",
startTime = "09:00",
endTime = "21:00",
)
val options = listOf(generatedOption(fixture.host.id, fixture.newPlace.id))
val ready = CountDownLatch(workers)
val start = CountDownLatch(1)
val pool = Executors.newFixedThreadPool(workers)

try {
val futures = (1..workers).map {
pool.submit<SavedScheduleGeneration> {
ready.countDown()
start.await()
generationPersistenceService.saveGeneratedOptions(
roomId = fixture.schedule.room.id,
dto = dto,
options = options,
personaValidationByType = emptyMap(),
)
}
}

assertTrue(ready.await(5, TimeUnit.SECONDS))
start.countDown()

val savedVersions = futures.map { it.get(20, TimeUnit.SECONDS).version }.sorted()

assertEquals(listOf(2, 3, 4, 5), savedVersions)
val persistedVersions = scheduleRepository.findByRoomId(fixture.schedule.room.id)
.filter { it.id != fixture.schedule.id }
.map { it.version }
.sorted()
assertEquals(listOf(2, 3, 4, 5), persistedVersions)
} finally {
pool.shutdownNow()
}
}

private fun createFixture(isConfirmed: Boolean = true): Fixture {
val suffix = System.nanoTime()
val host = userRepository.save(
Expand Down Expand Up @@ -186,6 +240,46 @@ class ScheduleServiceTest(
return Fixture(host, schedule, newPlace, windowStart, windowEnd)
}

private fun generatedOption(userId: Long, placeId: Long): ScheduleOptionDraft {
val start = Instant.parse("2026-06-01T00:00:00Z")
return ScheduleOptionDraft(
optionType = ScheduleOptionType.BALANCED,
label = "균형형",
summary = "동시 저장 테스트",
groupSatisfaction = 90,
slots = listOf(
ScheduleSlotDraft(
orderIndex = 1,
slotType = SlotType.COMMON,
targetUserId = null,
reasonAxis = ReasonAxis.COMMON,
reasonText = "공통 선호 장소",
startTime = start,
endTime = start.plusSeconds(3600),
placeId = placeId,
placeName = "태안 안면도 꽃지해수욕장",
placeAddress = "충청남도 태안군 안면읍 승언리",
isHiddenGem = false,
)
),
satisfactionByUser = listOf(
SatisfactionDraft(
userId = userId,
score = 90,
breakdown = SatisfactionDraft.Breakdown(
overall = 90,
byAxis = mapOf(ScoreAxis.MOBILITY to 90.0),
),
)
),
llmProvider = "fallback",
llmAttemptedProvider = "fallback",
llmLatencyMs = null,
fallbackUsed = true,
llmFallbackReason = "test",
)
}

private fun place(tourApiId: String, name: String, address: String): Place {
return Place(
tourApiId = tourApiId,
Expand Down
Loading