pcp-request-service: аудит-фиксы RS1/RS3/RS6/RS7
RS1 (#29): планировщик истечения заявок — RequestExpiryService (@Scheduled + FOR UPDATE SKIP LOCKED, мультиинстанс-safe), refreshStatus вызывается и при создании заявки. RS7 (#35): единый топик pcp.request.status.v1 → geoportal (COMPLETED/EXPIRED в payload), замена orphan pcp.request.completed.v1; эмиссия из matching и expiry через тот же outbox; backoff/next_attempt_at/cap для outbox FAILED. RS3 (#31): DefaultErrorHandler + DeadLetterPublishingRecoverer (DLQ pcp.route.georeference-dlq.v1), bounded retry, контрактные ошибки в DLQ. RS6 (#34): проброс coverageRequiredPercent из API, разведение терминов покрытие/важность. Доки: бэклог, аудит сервиса заданий, карты взаимодействий. RS2 (#30) отложено до #28, RS5 (#33) — до роста требований к точности.
This commit is contained in:
+131
@@ -0,0 +1,131 @@
|
||||
package org.nstart.dep265.requestservice.config
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import jakarta.validation.ConstraintViolationException
|
||||
import org.apache.kafka.clients.consumer.ConsumerConfig
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||
import org.apache.kafka.common.TopicPartition
|
||||
import org.apache.kafka.common.header.Headers
|
||||
import org.apache.kafka.common.header.internals.RecordHeader
|
||||
import org.apache.kafka.common.header.internals.RecordHeaders
|
||||
import org.apache.kafka.common.serialization.StringDeserializer
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
|
||||
import org.springframework.kafka.core.ConsumerFactory
|
||||
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
|
||||
import org.springframework.kafka.listener.DefaultErrorHandler
|
||||
import org.springframework.util.backoff.ExponentialBackOff
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* Обработка ошибок Kafka-консьюмера маршрутов: bounded retry + DLQ.
|
||||
*
|
||||
* Без этого «ядовитое» сообщение бесконечно ретраится и блокирует партицию. Невалидные по контракту
|
||||
* (ошибка десериализации/валидации) уходят в DLQ сразу, прочие — после ограниченного числа попыток.
|
||||
*
|
||||
* Активируется только при заданном `spring.kafka.bootstrap-servers`, чтобы не требовать Kafka в тестах
|
||||
* без брокера.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
|
||||
class KafkaConsumerConfig(
|
||||
@param:Value("\${spring.kafka.bootstrap-servers}")
|
||||
private val bootstrapServers: String,
|
||||
@param:Value("\${spring.kafka.consumer.group-id}")
|
||||
private val groupId: String,
|
||||
@param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}")
|
||||
private val autoOffsetReset: String,
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun routeConsumerFactory(): ConsumerFactory<String, String> =
|
||||
DefaultKafkaConsumerFactory(
|
||||
mutableMapOf<String, Any>(
|
||||
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||
ConsumerConfig.GROUP_ID_CONFIG to groupId,
|
||||
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset,
|
||||
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false,
|
||||
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||
),
|
||||
)
|
||||
|
||||
@Bean
|
||||
fun kafkaListenerContainerFactory(
|
||||
routeKafkaErrorHandler: DefaultErrorHandler,
|
||||
): ConcurrentKafkaListenerContainerFactory<String, String> =
|
||||
ConcurrentKafkaListenerContainerFactory<String, String>().apply {
|
||||
setConsumerFactory(routeConsumerFactory())
|
||||
setCommonErrorHandler(routeKafkaErrorHandler)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun routeDeadLetterPublishingRecoverer(
|
||||
kafkaTemplate: KafkaTemplate<String, String>,
|
||||
routeConsumerProperties: RouteConsumerProperties,
|
||||
): DeadLetterPublishingRecoverer =
|
||||
DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
|
||||
TopicPartition(routeConsumerProperties.dlqTopic, record.partition())
|
||||
}.apply {
|
||||
addHeadersFunction { record, exception -> routeDlqHeaders(record, exception) }
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun routeKafkaErrorHandler(
|
||||
routeDeadLetterPublishingRecoverer: DeadLetterPublishingRecoverer,
|
||||
routeConsumerProperties: RouteConsumerProperties,
|
||||
): DefaultErrorHandler =
|
||||
DefaultErrorHandler(
|
||||
routeDeadLetterPublishingRecoverer,
|
||||
routeConsumerBackOff(routeConsumerProperties),
|
||||
).apply {
|
||||
// Контрактные ошибки (битый JSON, провал валидации) бесполезно ретраить — сразу в DLQ.
|
||||
addNotRetryableExceptions(
|
||||
JsonProcessingException::class.java,
|
||||
ConstraintViolationException::class.java,
|
||||
)
|
||||
setCommitRecovered(true)
|
||||
}
|
||||
|
||||
private fun routeConsumerBackOff(properties: RouteConsumerProperties): ExponentialBackOff =
|
||||
ExponentialBackOff().apply {
|
||||
initialInterval = properties.backoffMs
|
||||
multiplier = RETRY_BACKOFF_MULTIPLIER
|
||||
maxInterval = properties.maxBackoffMs
|
||||
maxAttempts = (properties.maxAttempts - 1).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun routeDlqHeaders(
|
||||
record: ConsumerRecord<*, *>,
|
||||
exception: Exception,
|
||||
): Headers =
|
||||
RecordHeaders().apply {
|
||||
addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME)
|
||||
addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name)
|
||||
addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty())
|
||||
addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic())
|
||||
addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString())
|
||||
addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString())
|
||||
}
|
||||
|
||||
private fun Headers.addStringHeader(name: String, value: String) {
|
||||
add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8)))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_NAME = "pcp-request-service"
|
||||
const val RETRY_BACKOFF_MULTIPLIER = 2.0
|
||||
|
||||
const val HEADER_SERVICE_NAME = "pcp-service-name"
|
||||
const val HEADER_EXCEPTION_CLASS = "pcp-exception-class"
|
||||
const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message"
|
||||
const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic"
|
||||
const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition"
|
||||
const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset"
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -4,7 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
|
||||
@ConfigurationProperties(prefix = "pcp.outbox")
|
||||
data class OutboxProperties(
|
||||
val requestCompletedTopic: String = "pcp.request.completed.v1",
|
||||
/** Топик терминальных статусов заявки (COMPLETED/EXPIRED) для geoportal. */
|
||||
val requestStatusTopic: String = "pcp.request.status.v1",
|
||||
val publishBatchSize: Int = 50,
|
||||
val publishFixedDelayMs: Long = 5_000,
|
||||
/** Сколько раз пытаемся опубликовать событие до перевода в терминальный FAILED. */
|
||||
val maxAttempts: Int = 10,
|
||||
/** Backoff после первой неудачной публикации. */
|
||||
val initialBackoffMs: Long = 1_000,
|
||||
/** Верхняя граница backoff между попытками. */
|
||||
val maxBackoffMs: Long = 60_000,
|
||||
)
|
||||
|
||||
+11
@@ -22,4 +22,15 @@ class OutboxTransactionConfig {
|
||||
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Каждый батч планировщика истечения коммитится отдельно, поэтому между батчами
|
||||
* row-level блокировки (FOR UPDATE SKIP LOCKED) освобождаются.
|
||||
*/
|
||||
@Bean("requestExpiryTransactionOperations")
|
||||
fun requestExpiryTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations {
|
||||
return TransactionTemplate(transactionManager).apply {
|
||||
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package org.nstart.dep265.requestservice.config
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
|
||||
/**
|
||||
* Настройки планировщика истечения заявок: периодически переводит просроченные заявки
|
||||
* (status ACCEPTED/ACTIVE, окно которых уже закончилось) в EXPIRED.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "pcp.request.expiry")
|
||||
data class RequestExpiryProperties(
|
||||
/** Включён ли планировщик. */
|
||||
val enabled: Boolean = true,
|
||||
/** Период запуска (мс), читается также в @Scheduled. */
|
||||
val fixedDelayMs: Long = 60_000,
|
||||
/** Сколько заявок захватывать за одну транзакцию (FOR UPDATE SKIP LOCKED). */
|
||||
val batchSize: Int = 200,
|
||||
/** Верхняя граница числа батчей за один прогон, чтобы не удерживать поток планировщика. */
|
||||
val maxBatchesPerRun: Int = 50,
|
||||
)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package org.nstart.dep265.requestservice.config
|
||||
|
||||
import jakarta.validation.constraints.Min
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.validation.annotation.Validated
|
||||
|
||||
/**
|
||||
* Настройки обработки ошибок Kafka-консьюмера маршрутов: DLQ-топик и bounded retry.
|
||||
* «Ядовитое» (невалидное по контракту) сообщение уходит в DLQ, а не блокирует партицию.
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "app.kafka.consumer.route")
|
||||
data class RouteConsumerProperties(
|
||||
/** Топик dead-letter для маршрутов, не прошедших обработку. */
|
||||
val dlqTopic: String = "pcp.route.georeference-dlq.v1",
|
||||
|
||||
/** Максимум попыток обработки, включая первичную доставку. */
|
||||
@field:Min(1)
|
||||
val maxAttempts: Long = 5,
|
||||
|
||||
/** Начальная задержка retry, мс. */
|
||||
@field:Min(0)
|
||||
val backoffMs: Long = 1_000,
|
||||
|
||||
/** Верхняя граница задержки retry, мс. */
|
||||
@field:Min(0)
|
||||
val maxBackoffMs: Long = 30_000,
|
||||
)
|
||||
+9
@@ -60,6 +60,15 @@ data class CreateRequestDto(
|
||||
/** Признак высокоприоритетной передачи. */
|
||||
val highPriorityTransmit: Boolean = false,
|
||||
|
||||
/**
|
||||
* Требуемый процент покрытия заявки (0 < x ≤ 100), при достижении которого заявка
|
||||
* считается выполненной (COMPLETED). Это **прогресс съёмки**, не путать с [importance]
|
||||
* (важность заявки для карты приоритетов ячеек). По умолчанию — 100%.
|
||||
*/
|
||||
@field:DecimalMin(value = "0", inclusive = false)
|
||||
@field:DecimalMax("100")
|
||||
val coverageRequiredPercent: Double = 100.0,
|
||||
|
||||
/** Параметры оптической съемки. */
|
||||
@field:Valid
|
||||
val optics: OpticsParamsDto? = null,
|
||||
|
||||
+8
@@ -48,6 +48,14 @@ class OutboxEventEntity(
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime,
|
||||
|
||||
/** Число выполненных попыток публикации. */
|
||||
@Column(name = "attempts", nullable = false)
|
||||
var attempts: Int = 0,
|
||||
|
||||
/** Не раньше этого момента событие снова попадёт в выборку публикации (backoff). */
|
||||
@Column(name = "next_attempt_at", nullable = false)
|
||||
var nextAttemptAt: LocalDateTime = createdAt,
|
||||
|
||||
@Column(name = "published_at")
|
||||
var publishedAt: LocalDateTime? = null,
|
||||
|
||||
|
||||
+12
-3
@@ -16,8 +16,9 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
|
||||
fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List<OutboxEventEntity>
|
||||
|
||||
/**
|
||||
* Locks publishable REQUEST_COMPLETED events so parallel service instances skip rows already
|
||||
* being published by another transaction.
|
||||
* Locks publishable events so parallel service instances skip rows already being published by
|
||||
* another transaction. Учитывает backoff (`next_attempt_at`) и предел попыток (`attempts`):
|
||||
* терминальные FAILED (`attempts >= maxAttempts`) и ещё не «дозревшие» строки исключаются.
|
||||
*/
|
||||
@Query(
|
||||
value = """
|
||||
@@ -25,6 +26,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
|
||||
FROM outbox_events
|
||||
WHERE event_type = :eventType
|
||||
AND status IN ('NEW', 'FAILED')
|
||||
AND attempts < :maxAttempts
|
||||
AND next_attempt_at <= :now
|
||||
ORDER BY
|
||||
CASE WHEN status = 'NEW' THEN 0 ELSE 1 END,
|
||||
created_at ASC
|
||||
@@ -33,8 +36,10 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
|
||||
""",
|
||||
nativeQuery = true,
|
||||
)
|
||||
fun findRequestCompletedPublishableForUpdateSkipLocked(
|
||||
fun findPublishableForUpdateSkipLocked(
|
||||
@Param("eventType") eventType: String,
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("maxAttempts") maxAttempts: Int,
|
||||
@Param("batchSize") batchSize: Int,
|
||||
): List<OutboxEventEntity>
|
||||
|
||||
@@ -52,6 +57,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
|
||||
aggregate_id,
|
||||
payload,
|
||||
status,
|
||||
attempts,
|
||||
next_attempt_at,
|
||||
created_at,
|
||||
published_at,
|
||||
error_message
|
||||
@@ -63,6 +70,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
|
||||
:aggregateId,
|
||||
CAST(:payload AS jsonb),
|
||||
:status,
|
||||
0,
|
||||
:createdAt,
|
||||
:createdAt,
|
||||
NULL,
|
||||
NULL
|
||||
|
||||
+23
@@ -106,4 +106,27 @@ interface RequestRepository : JpaRepository<RequestEntity, UUID> {
|
||||
): List<RequestEntity>
|
||||
|
||||
fun findByStatusIn(statuses: Collection<RequestStatus>): List<RequestEntity>
|
||||
|
||||
/**
|
||||
* Захватывает порцию просроченных заявок (окно уже закончилось, статус ещё незавершённый)
|
||||
* под row-level lock. FOR UPDATE SKIP LOCKED позволяет нескольким инстансам разбирать
|
||||
* непересекающиеся строки, не конкурируя за одни и те же заявки.
|
||||
*/
|
||||
@Query(
|
||||
value = """
|
||||
SELECT *
|
||||
FROM requests
|
||||
WHERE status IN ('ACCEPTED', 'ACTIVE')
|
||||
AND end_date_time < :now
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY end_date_time ASC
|
||||
LIMIT :batchSize
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
nativeQuery = true,
|
||||
)
|
||||
fun findExpirableForUpdateSkipLocked(
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("batchSize") batchSize: Int,
|
||||
): List<RequestEntity>
|
||||
}
|
||||
|
||||
+43
-11
@@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.support.TransactionCallback
|
||||
import org.springframework.transaction.support.TransactionOperations
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.ExecutionException
|
||||
|
||||
@@ -41,60 +42,91 @@ class OutboxPublisherService(
|
||||
return try {
|
||||
transactionOperations.execute(
|
||||
TransactionCallback {
|
||||
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked(
|
||||
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
|
||||
val now = LocalDateTime.now()
|
||||
val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
|
||||
eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE,
|
||||
now = now,
|
||||
maxAttempts = outboxProperties.maxAttempts,
|
||||
batchSize = batchSize,
|
||||
).distinctBy { it.id }
|
||||
|
||||
events.forEach { event ->
|
||||
publishAndUpdateStatus(event)
|
||||
publishAndUpdateStatus(event, now)
|
||||
}
|
||||
events.size
|
||||
},
|
||||
) ?: 0
|
||||
} catch (exception: Exception) {
|
||||
log.error("Failed to process REQUEST_COMPLETED outbox event batch", exception)
|
||||
log.error("Failed to process REQUEST_TERMINAL_STATUS outbox event batch", exception)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishAndUpdateStatus(event: OutboxEventEntity) {
|
||||
private fun publishAndUpdateStatus(event: OutboxEventEntity, now: LocalDateTime) {
|
||||
try {
|
||||
kafkaTemplate
|
||||
.send(
|
||||
outboxProperties.requestCompletedTopic,
|
||||
outboxProperties.requestStatusTopic,
|
||||
event.aggregateId.toString(),
|
||||
event.payload.toString(),
|
||||
)
|
||||
.get()
|
||||
|
||||
event.status = OutboxEventStatus.PUBLISHED
|
||||
event.publishedAt = LocalDateTime.now()
|
||||
event.publishedAt = now
|
||||
event.errorMessage = null
|
||||
outboxEventRepository.save(event)
|
||||
|
||||
log.info(
|
||||
"Published REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}",
|
||||
"Published REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, topic={}",
|
||||
event.id,
|
||||
event.aggregateId,
|
||||
outboxProperties.requestCompletedTopic,
|
||||
outboxProperties.requestStatusTopic,
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
val publishException = exception.unwrapExecutionException()
|
||||
|
||||
event.attempts += 1
|
||||
event.status = OutboxEventStatus.FAILED
|
||||
event.errorMessage = publishException.shortMessage()
|
||||
// attempts >= maxAttempts → терминальный FAILED (выборка публикации его уже не вернёт);
|
||||
// иначе планируем повтор с backoff.
|
||||
if (event.attempts < outboxProperties.maxAttempts) {
|
||||
event.nextAttemptAt = now.plus(Duration.ofMillis(calculateBackoffMs(event.attempts)))
|
||||
}
|
||||
outboxEventRepository.save(event)
|
||||
|
||||
val exhausted = event.attempts >= outboxProperties.maxAttempts
|
||||
log.warn(
|
||||
"Failed to publish REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}",
|
||||
"Failed to publish REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, " +
|
||||
"topic={}, attempts={}, maxAttempts={}, exhausted={}, nextAttemptAt={}",
|
||||
event.id,
|
||||
event.aggregateId,
|
||||
outboxProperties.requestCompletedTopic,
|
||||
outboxProperties.requestStatusTopic,
|
||||
event.attempts,
|
||||
outboxProperties.maxAttempts,
|
||||
exhausted,
|
||||
event.nextAttemptAt,
|
||||
publishException,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удваивает backoff от [OutboxProperties.initialBackoffMs] до [OutboxProperties.maxBackoffMs].
|
||||
*/
|
||||
private fun calculateBackoffMs(attempts: Int): Long {
|
||||
var backoffMs = outboxProperties.initialBackoffMs.coerceAtMost(outboxProperties.maxBackoffMs)
|
||||
repeat((attempts - 1).coerceAtLeast(0)) {
|
||||
backoffMs = if (backoffMs >= outboxProperties.maxBackoffMs / 2) {
|
||||
outboxProperties.maxBackoffMs
|
||||
} else {
|
||||
(backoffMs * 2).coerceAtMost(outboxProperties.maxBackoffMs)
|
||||
}
|
||||
}
|
||||
return backoffMs
|
||||
}
|
||||
|
||||
private fun Exception.unwrapExecutionException(): Throwable {
|
||||
return if (this is ExecutionException && cause != null) {
|
||||
cause!!
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package org.nstart.dep265.requestservice.service
|
||||
|
||||
import org.nstart.dep265.requestservice.config.RequestExpiryProperties
|
||||
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||
import org.nstart.dep265.requestservice.repository.RequestRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.support.TransactionCallback
|
||||
import org.springframework.transaction.support.TransactionOperations
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* Планировщик истечения заявок.
|
||||
*
|
||||
* [RequestLifecycleService.refreshStatus] вызывается только при матчинге маршрута, поэтому заявка,
|
||||
* к которой не пришло ни одного маршрута, никогда не уходила в EXPIRED после окончания окна.
|
||||
* Этот воркер периодически переводит такие заявки в EXPIRED.
|
||||
*
|
||||
* Несколько инстансов безопасны: батч строк захватывается через FOR UPDATE SKIP LOCKED, так что
|
||||
* параллельные инстансы разбирают непересекающиеся заявки.
|
||||
*/
|
||||
@Service
|
||||
class RequestExpiryService(
|
||||
private val requestRepository: RequestRepository,
|
||||
|
||||
private val requestLifecycleService: RequestLifecycleService,
|
||||
|
||||
private val requestStatusOutboxService: RequestStatusOutboxService,
|
||||
|
||||
private val requestExpiryProperties: RequestExpiryProperties,
|
||||
|
||||
@param:Qualifier("requestExpiryTransactionOperations")
|
||||
private val transactionOperations: TransactionOperations,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Scheduled(fixedDelayString = "\${pcp.request.expiry.fixed-delay-ms:60000}")
|
||||
fun expireOverdueScheduledBatch() {
|
||||
if (!requestExpiryProperties.enabled) {
|
||||
return
|
||||
}
|
||||
expireOverdue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Переводит просроченные заявки (ACCEPTED/ACTIVE с истёкшим окном) в EXPIRED.
|
||||
*
|
||||
* @return число заявок, перешедших в EXPIRED за прогон.
|
||||
*/
|
||||
fun expireOverdue(): Int {
|
||||
val batchSize = requestExpiryProperties.batchSize.coerceAtLeast(0)
|
||||
if (batchSize == 0) {
|
||||
return 0
|
||||
}
|
||||
val maxBatches = requestExpiryProperties.maxBatchesPerRun.coerceAtLeast(1)
|
||||
val now = LocalDateTime.now()
|
||||
|
||||
var expiredTotal = 0
|
||||
var batchIndex = 0
|
||||
while (batchIndex < maxBatches) {
|
||||
val result = try {
|
||||
transactionOperations.execute(
|
||||
TransactionCallback { expireBatch(now, batchSize) },
|
||||
) ?: ExpireBatchResult.EMPTY
|
||||
} catch (exception: Exception) {
|
||||
log.error("Failed to process request expiry batch", exception)
|
||||
break
|
||||
}
|
||||
|
||||
expiredTotal += result.expiredCount
|
||||
batchIndex += 1
|
||||
if (result.claimedCount < batchSize) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredTotal > 0) {
|
||||
log.info("Request expiry sweep finished: expired={}", expiredTotal)
|
||||
}
|
||||
return expiredTotal
|
||||
}
|
||||
|
||||
private fun expireBatch(now: LocalDateTime, batchSize: Int): ExpireBatchResult {
|
||||
val candidates = requestRepository.findExpirableForUpdateSkipLocked(now, batchSize)
|
||||
var expiredCount = 0
|
||||
|
||||
candidates.forEach { request ->
|
||||
val refresh = requestLifecycleService.refreshStatus(request, now)
|
||||
if (refresh.statusChanged) {
|
||||
request.updatedAt = now
|
||||
requestRepository.save(request)
|
||||
if (request.status == RequestStatus.EXPIRED) {
|
||||
expiredCount += 1
|
||||
// Терминальный статус → уведомляем geoportal через тот же transactional outbox.
|
||||
requestStatusOutboxService.createTerminalIfAbsent(request, now)
|
||||
log.info(
|
||||
"Request expired: requestId={}, endDateTime={}",
|
||||
request.id,
|
||||
request.endDateTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ExpireBatchResult(claimedCount = candidates.size, expiredCount = expiredCount)
|
||||
}
|
||||
|
||||
private data class ExpireBatchResult(
|
||||
val claimedCount: Int,
|
||||
val expiredCount: Int,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = ExpireBatchResult(claimedCount = 0, expiredCount = 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -51,6 +51,7 @@ class RequestService(
|
||||
private val requestCellRepository: RequestCellRepository,
|
||||
private val geometryService: GeometryService,
|
||||
private val requestGridProjectionService: RequestGridProjectionService,
|
||||
private val requestLifecycleService: RequestLifecycleService,
|
||||
private val entityManager: EntityManager,
|
||||
) {
|
||||
@Transactional
|
||||
@@ -78,7 +79,9 @@ class RequestService(
|
||||
remainingGeometry = normalizedGeometryWkt,
|
||||
geometryArea = geometryArea,
|
||||
remainingArea = geometryArea,
|
||||
coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT,
|
||||
// Порог завершения по покрытию приходит из API (по умолчанию 100%); это прогресс съёмки,
|
||||
// в отличие от importance — важности заявки для карты приоритетов ячеек.
|
||||
coverageRequiredPercent = request.coverageRequiredPercent,
|
||||
importance = importance,
|
||||
beginDateTime = request.beginDateTime,
|
||||
endDateTime = request.endDateTime,
|
||||
@@ -90,6 +93,10 @@ class RequestService(
|
||||
updatedAt = now,
|
||||
)
|
||||
|
||||
// Окно заявки может уже идти или закончиться к моменту приёма — сразу выставляем
|
||||
// корректный статус (ACCEPTED/ACTIVE/EXPIRED), а не всегда ACCEPTED.
|
||||
requestLifecycleService.refreshStatus(requestEntity, now)
|
||||
|
||||
persistNewRequest(requestEntity)
|
||||
replaceSurveyParams(requestEntity.id, request.optics, request.rsa)
|
||||
requestGridProjectionService.rebuildProjection(requestEntity)
|
||||
@@ -97,7 +104,7 @@ class RequestService(
|
||||
return CreateRequestResponseDto(
|
||||
id = requestEntity.id,
|
||||
name = requestEntity.name,
|
||||
status = RequestStatusDto.ACCEPTED,
|
||||
status = requestEntity.status.toDto(),
|
||||
surveyType = requestEntity.surveyType.toDto(),
|
||||
importance = requestEntity.importance,
|
||||
message = "Заявка успешно создана",
|
||||
@@ -369,7 +376,6 @@ class RequestService(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0
|
||||
const val POSTGRES_UNIQUE_VIOLATION = "23505"
|
||||
const val REQUESTS_PRIMARY_KEY = "requests_pkey"
|
||||
const val DEFAULT_SORT_FIELD = "createdAt"
|
||||
|
||||
+23
-11
@@ -12,8 +12,15 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Складывает в transactional outbox единое событие о **терминальном** статусе заявки
|
||||
* (`COMPLETED` или `EXPIRED`) для geoportal. Идемпотентно по `(event_type, aggregate_id)`:
|
||||
* на заявку приходится не более одного терминального события.
|
||||
*
|
||||
* Публикуется [OutboxPublisherService] в топик `pcp.request.status.v1` со статусом в payload.
|
||||
*/
|
||||
@Service
|
||||
class RequestCompletedOutboxService(
|
||||
class RequestStatusOutboxService(
|
||||
private val outboxEventRepository: OutboxEventRepository,
|
||||
|
||||
private val objectMapper: ObjectMapper,
|
||||
@@ -23,12 +30,17 @@ class RequestCompletedOutboxService(
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
fun createRequestCompletedIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) {
|
||||
val completedAt = request.completedAt ?: return
|
||||
val payload = RequestCompletedPayload(
|
||||
fun createTerminalIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) {
|
||||
val status = request.status
|
||||
require(status == RequestStatus.COMPLETED || status == RequestStatus.EXPIRED) {
|
||||
"Терминальное outbox-событие требует COMPLETED/EXPIRED, получено: $status"
|
||||
}
|
||||
|
||||
val payload = RequestStatusPayload(
|
||||
requestId = request.id,
|
||||
status = RequestStatus.COMPLETED.name,
|
||||
completedAt = completedAt,
|
||||
status = status.name,
|
||||
// Для COMPLETED — момент достижения покрытия, для EXPIRED — момент фиксации истечения.
|
||||
occurredAt = request.completedAt ?: eventCreatedAt,
|
||||
coveragePercent = geometryService.coveredPercent(request.geometryArea, request.remainingArea),
|
||||
matchedAt = request.lastMatchedAt,
|
||||
eventCreatedAt = eventCreatedAt,
|
||||
@@ -36,7 +48,7 @@ class RequestCompletedOutboxService(
|
||||
|
||||
val inserted = outboxEventRepository.insertIfAbsent(
|
||||
id = UUID.randomUUID(),
|
||||
eventType = REQUEST_COMPLETED_EVENT_TYPE,
|
||||
eventType = REQUEST_STATUS_EVENT_TYPE,
|
||||
aggregateType = REQUEST_AGGREGATE_TYPE,
|
||||
aggregateId = request.id,
|
||||
payload = objectMapper.writeValueAsString(payload),
|
||||
@@ -46,23 +58,23 @@ class RequestCompletedOutboxService(
|
||||
|
||||
if (inserted == 0) {
|
||||
log.debug(
|
||||
"REQUEST_COMPLETED outbox event already exists for requestId={}",
|
||||
"Терминальное outbox-событие уже существует для requestId={}",
|
||||
request.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class RequestCompletedPayload(
|
||||
private data class RequestStatusPayload(
|
||||
val requestId: UUID,
|
||||
val status: String,
|
||||
val completedAt: LocalDateTime,
|
||||
val occurredAt: LocalDateTime,
|
||||
val coveragePercent: Double,
|
||||
val matchedAt: LocalDateTime?,
|
||||
val eventCreatedAt: LocalDateTime,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val REQUEST_COMPLETED_EVENT_TYPE = "REQUEST_COMPLETED"
|
||||
const val REQUEST_STATUS_EVENT_TYPE = "REQUEST_TERMINAL_STATUS"
|
||||
const val REQUEST_AGGREGATE_TYPE = "REQUEST"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -31,7 +31,7 @@ class RouteMatchingService(
|
||||
|
||||
private val requestLifecycleService: RequestLifecycleService,
|
||||
|
||||
private val requestCompletedOutboxService: RequestCompletedOutboxService,
|
||||
private val requestStatusOutboxService: RequestStatusOutboxService,
|
||||
|
||||
@param:Qualifier("routeMatchingTransactionOperations")
|
||||
private val routeMatchingTransactionOperations: TransactionOperations,
|
||||
@@ -168,7 +168,7 @@ class RouteMatchingService(
|
||||
val statusRefresh = requestLifecycleService.refreshStatus(request, matchedAt)
|
||||
requestRepository.save(request)
|
||||
if (statusRefresh.completedNow) {
|
||||
requestCompletedOutboxService.createRequestCompletedIfAbsent(request, matchedAt)
|
||||
requestStatusOutboxService.createTerminalIfAbsent(request, matchedAt)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ spring:
|
||||
|
||||
pcp:
|
||||
outbox:
|
||||
request-completed-topic: pcp.request.completed.v1
|
||||
request-status-topic: pcp.request.status.v1
|
||||
publish-batch-size: 50
|
||||
publish-fixed-delay-ms: 5000
|
||||
max-attempts: 10
|
||||
initial-backoff-ms: 1000
|
||||
max-backoff-ms: 60000
|
||||
|
||||
@@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS outbox_events (
|
||||
aggregate_id UUID NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
published_at TIMESTAMP NULL,
|
||||
error_message TEXT NULL,
|
||||
@@ -203,6 +205,7 @@ CREATE INDEX IF NOT EXISTS idx_request_kpp_request_id ON request_kpp(request_id)
|
||||
CREATE INDEX IF NOT EXISTS idx_request_kpp_kpp ON request_kpp(kpp);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_route_matches_route_id ON request_route_matches(route_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_events_status_created_at ON outbox_events(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_events_due ON outbox_events(event_type, status, next_attempt_at, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_earth_cells_cell_num ON earth_cells(cell_num);
|
||||
CREATE INDEX IF NOT EXISTS idx_earth_cells_contour_geom ON earth_cells USING GIST (contour_geom);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_cells_request_id ON request_cells(request_id);
|
||||
|
||||
+3
@@ -49,8 +49,11 @@ class MigrationSchemaTest {
|
||||
|
||||
assertTrue(outboxEvents.contains("payload jsonb not null"))
|
||||
assertTrue(outboxEvents.contains("status varchar(32) not null"))
|
||||
assertTrue(outboxEvents.contains("attempts integer not null default 0"))
|
||||
assertTrue(outboxEvents.contains("next_attempt_at timestamp not null"))
|
||||
assertTrue(outboxEvents.contains("constraint uk_outbox_events_event_type_aggregate_id unique (event_type, aggregate_id)"))
|
||||
assertTrue(schema.contains("on outbox_events(status, created_at)"))
|
||||
assertTrue(schema.contains("on outbox_events(event_type, status, next_attempt_at, created_at)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package org.nstart.dep265.requestservice.config
|
||||
|
||||
import jakarta.validation.ConstraintViolation
|
||||
import jakarta.validation.ConstraintViolationException
|
||||
import org.apache.kafka.clients.consumer.Consumer
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||
import org.apache.kafka.clients.producer.ProducerRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations
|
||||
import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
|
||||
import org.springframework.kafka.listener.DefaultErrorHandler
|
||||
import org.springframework.kafka.listener.MessageListenerContainer
|
||||
import org.springframework.kafka.support.SendResult
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
class KafkaConsumerConfigTest {
|
||||
|
||||
private val contextRunner = ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration::class.java))
|
||||
.withUserConfiguration(TestConfiguration::class.java, KafkaConsumerConfig::class.java)
|
||||
.withPropertyValues(
|
||||
"spring.kafka.bootstrap-servers=localhost:9092",
|
||||
"spring.kafka.consumer.group-id=pcp-request-service",
|
||||
"spring.kafka.consumer.auto-offset-reset=earliest",
|
||||
"app.kafka.consumer.route.dlq-topic=$DLQ_TOPIC",
|
||||
"app.kafka.consumer.route.max-attempts=3",
|
||||
"app.kafka.consumer.route.backoff-ms=0",
|
||||
"app.kafka.consumer.route.max-backoff-ms=0",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `creates error handler and recoverer beans`() {
|
||||
contextRunner.run { context ->
|
||||
assertNotNull(context.getBean(DefaultErrorHandler::class.java))
|
||||
assertNotNull(context.getBean(DeadLetterPublishingRecoverer::class.java))
|
||||
|
||||
val properties = context.getBean(RouteConsumerProperties::class.java)
|
||||
assertEquals(DLQ_TOPIC, properties.dlqTopic)
|
||||
assertEquals(3L, properties.maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverer publishes original record to configured dlq topic with diagnostics headers`() {
|
||||
contextRunner.run { context ->
|
||||
val kafkaTemplate = context.kafkaTemplate()
|
||||
val recoverer = context.getBean(DeadLetterPublishingRecoverer::class.java)
|
||||
val record = consumerRecord()
|
||||
|
||||
recoverer.accept(record, ConstraintViolationException("route payload invalid", emptySet<ConstraintViolation<*>>()))
|
||||
|
||||
val published = capturePublishedRecord(kafkaTemplate)
|
||||
assertEquals(DLQ_TOPIC, published.topic())
|
||||
assertEquals(record.partition(), published.partition())
|
||||
assertEquals(record.key(), published.key())
|
||||
assertEquals(record.value(), published.value())
|
||||
assertEquals("pcp-request-service", published.headers().stringHeader("pcp-service-name"))
|
||||
assertEquals(
|
||||
ConstraintViolationException::class.java.name,
|
||||
published.headers().stringHeader("pcp-exception-class"),
|
||||
)
|
||||
assertEquals("route payload invalid", published.headers().stringHeader("pcp-exception-message"))
|
||||
assertEquals(ROUTE_TOPIC, published.headers().stringHeader("pcp-original-topic"))
|
||||
assertEquals("3", published.headers().stringHeader("pcp-original-partition"))
|
||||
assertEquals("42", published.headers().stringHeader("pcp-original-offset"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contract violation is recovered to dlq without retry`() {
|
||||
contextRunner.run { context ->
|
||||
val kafkaTemplate = context.kafkaTemplate()
|
||||
val errorHandler = context.getBean(DefaultErrorHandler::class.java)
|
||||
|
||||
val handled = errorHandler.handleOne(
|
||||
ConstraintViolationException("route payload invalid", emptySet<ConstraintViolation<*>>()),
|
||||
consumerRecord(),
|
||||
mockConsumer(),
|
||||
mock(MessageListenerContainer::class.java),
|
||||
)
|
||||
|
||||
assertTrue(handled)
|
||||
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient exception is retried with bounded policy before dlq recovery`() {
|
||||
contextRunner.run { context ->
|
||||
val kafkaTemplate = context.kafkaTemplate()
|
||||
val errorHandler = context.getBean(DefaultErrorHandler::class.java)
|
||||
val record = consumerRecord()
|
||||
val consumer = mockConsumer()
|
||||
val container = mock(MessageListenerContainer::class.java)
|
||||
val exception = IllegalStateException("database is temporarily unavailable")
|
||||
|
||||
// maxAttempts=3 → две первые доставки ретраятся (seek), на третьей — DLQ.
|
||||
assertFalse(errorHandler.handleOne(exception, record, consumer, container))
|
||||
assertFalse(errorHandler.handleOne(exception, record, consumer, container))
|
||||
verify(kafkaTemplate, never()).send(anyProducerRecord())
|
||||
|
||||
assertTrue(errorHandler.handleOne(exception, record, consumer, container))
|
||||
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumerRecord(): ConsumerRecord<String, String> =
|
||||
ConsumerRecord(ROUTE_TOPIC, 3, 42L, "route-key", """{"routeId":"route-1"}""")
|
||||
|
||||
private fun capturePublishedRecord(
|
||||
kafkaTemplate: KafkaTemplate<String, String>,
|
||||
): ProducerRecord<String, String> {
|
||||
val captor = producerRecordCaptor()
|
||||
verify(kafkaTemplate, times(1)).send(captor.capture())
|
||||
return captor.value
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun producerRecordCaptor(): ArgumentCaptor<ProducerRecord<String, String>> =
|
||||
ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor<ProducerRecord<String, String>>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun mockConsumer(): Consumer<String, String> =
|
||||
mock(Consumer::class.java) as Consumer<String, String>
|
||||
|
||||
private fun org.apache.kafka.common.header.Headers.stringHeader(name: String): String? =
|
||||
lastHeader(name)?.value()?.toString(StandardCharsets.UTF_8)
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(RouteConsumerProperties::class)
|
||||
private class TestConfiguration {
|
||||
|
||||
@Bean
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun kafkaTemplate(): KafkaTemplate<String, String> {
|
||||
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
|
||||
val sendResult = mock(SendResult::class.java) as SendResult<String, String>
|
||||
`when`(kafkaTemplate.send(anyProducerRecord()))
|
||||
.thenReturn(CompletableFuture.completedFuture(sendResult))
|
||||
return kafkaTemplate
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ROUTE_TOPIC = "pcp.route.georeference.v1"
|
||||
const val DLQ_TOPIC = "pcp.route.georeference-dlq.v1"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun anyProducerRecord(): ProducerRecord<String, String> =
|
||||
(any(ProducerRecord::class.java) as ProducerRecord<String, String>?)
|
||||
?: ProducerRecord("stub-topic", "stub-key", "stub-payload")
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun org.springframework.context.ApplicationContext.kafkaTemplate(): KafkaTemplate<String, String> =
|
||||
getBean(KafkaTemplate::class.java) as KafkaTemplate<String, String>
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -25,6 +25,7 @@ import org.nstart.dep265.requestservice.repository.RequestRepository
|
||||
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
|
||||
import org.nstart.dep265.requestservice.service.GeometryService
|
||||
import org.nstart.dep265.requestservice.service.RequestGridProjectionService
|
||||
import org.nstart.dep265.requestservice.service.RequestLifecycleService
|
||||
import org.nstart.dep265.requestservice.service.RequestService
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
@@ -98,6 +99,7 @@ class RequestControllerOpenApiSkeletonTest {
|
||||
requestCellRepository = requestCellRepository,
|
||||
geometryService = GeometryService(),
|
||||
requestGridProjectionService = mock(RequestGridProjectionService::class.java),
|
||||
requestLifecycleService = RequestLifecycleService(GeometryService()),
|
||||
entityManager = entityManager,
|
||||
)
|
||||
)
|
||||
@@ -114,6 +116,7 @@ class RequestControllerOpenApiSkeletonTest {
|
||||
|
||||
@Test
|
||||
fun `post v1 requests returns create response`() {
|
||||
// Окно в будущем → приём детерминированно даёт ACCEPTED (createRequest зовёт refreshStatus).
|
||||
mockMvc.perform(
|
||||
post("/v1/requests")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -125,8 +128,8 @@ class RequestControllerOpenApiSkeletonTest {
|
||||
"name": "Тест1",
|
||||
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
|
||||
"importance": 10.0,
|
||||
"beginDateTime": "2026-01-01T00:00:00Z",
|
||||
"endDateTime": "2026-01-31T23:59:59Z",
|
||||
"beginDateTime": "2090-01-01T00:00:00Z",
|
||||
"endDateTime": "2090-01-31T23:59:59Z",
|
||||
"kpp": [1, 2, 3],
|
||||
"highPriorityTransmit": false,
|
||||
"optics": {
|
||||
|
||||
+25
@@ -45,6 +45,31 @@ class RequestApiDtoContractTest {
|
||||
assertEquals(10.0, request.importance)
|
||||
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime)
|
||||
assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType)
|
||||
// Поле опционально: при отсутствии в payload — порог покрытия по умолчанию 100%.
|
||||
assertEquals(100.0, request.coverageRequiredPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create request dto binds explicit coverage required percent`() {
|
||||
val request = objectMapper.readValue<CreateRequestDto>(
|
||||
"""
|
||||
{
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"name": "Тест1",
|
||||
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
|
||||
"importance": 10.0,
|
||||
"beginDateTime": "2026-01-01T00:00:00",
|
||||
"endDateTime": "2026-01-31T23:59:59",
|
||||
"coverageRequiredPercent": 80.0,
|
||||
"optics": {
|
||||
"resultType": "PANCHROMATIC",
|
||||
"resolution": 1.0
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(80.0, request.coverageRequiredPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+63
-3
@@ -82,19 +82,77 @@ class OutboxEventRepositoryJpaTest @Autowired constructor(
|
||||
)
|
||||
outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType))
|
||||
|
||||
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked(
|
||||
eventType = "REQUEST_COMPLETED",
|
||||
val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
|
||||
eventType = "REQUEST_TERMINAL_STATUS",
|
||||
now = LocalDateTime.parse("2026-06-06T00:00:00"),
|
||||
maxAttempts = 10,
|
||||
batchSize = 10,
|
||||
)
|
||||
|
||||
assertEquals(listOf(newEvent.id, oldFailedEvent.id, newerFailedEvent.id), events.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
fun `publishable query excludes events whose backoff has not elapsed`() {
|
||||
val now = LocalDateTime.parse("2026-06-06T00:00:00")
|
||||
val dueFailed = outboxEvent(
|
||||
requestId = UUID.fromString("aaaaaaaa-0000-4000-8000-000000000001"),
|
||||
status = OutboxEventStatus.FAILED,
|
||||
attempts = 1,
|
||||
nextAttemptAt = now.minusMinutes(1),
|
||||
)
|
||||
val backoffPendingFailed = outboxEvent(
|
||||
requestId = UUID.fromString("bbbbbbbb-0000-4000-8000-000000000002"),
|
||||
status = OutboxEventStatus.FAILED,
|
||||
attempts = 1,
|
||||
nextAttemptAt = now.plusMinutes(5),
|
||||
)
|
||||
outboxEventRepository.saveAllAndFlush(listOf(dueFailed, backoffPendingFailed))
|
||||
|
||||
val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
|
||||
eventType = "REQUEST_TERMINAL_STATUS",
|
||||
now = now,
|
||||
maxAttempts = 10,
|
||||
batchSize = 10,
|
||||
)
|
||||
|
||||
assertEquals(listOf(dueFailed.id), events.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
fun `publishable query excludes events that exhausted max attempts`() {
|
||||
val now = LocalDateTime.parse("2026-06-06T00:00:00")
|
||||
val retriable = outboxEvent(
|
||||
requestId = UUID.fromString("cccccccc-0000-4000-8000-000000000003"),
|
||||
status = OutboxEventStatus.FAILED,
|
||||
attempts = 9,
|
||||
)
|
||||
val exhausted = outboxEvent(
|
||||
requestId = UUID.fromString("dddddddd-0000-4000-8000-000000000004"),
|
||||
status = OutboxEventStatus.FAILED,
|
||||
attempts = 10,
|
||||
)
|
||||
outboxEventRepository.saveAllAndFlush(listOf(retriable, exhausted))
|
||||
|
||||
val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
|
||||
eventType = "REQUEST_TERMINAL_STATUS",
|
||||
now = now,
|
||||
maxAttempts = 10,
|
||||
batchSize = 10,
|
||||
)
|
||||
|
||||
assertEquals(listOf(retriable.id), events.map { it.id })
|
||||
}
|
||||
|
||||
private fun outboxEvent(
|
||||
requestId: UUID,
|
||||
eventType: String = "REQUEST_COMPLETED",
|
||||
eventType: String = "REQUEST_TERMINAL_STATUS",
|
||||
status: OutboxEventStatus = OutboxEventStatus.NEW,
|
||||
createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
|
||||
attempts: Int = 0,
|
||||
nextAttemptAt: LocalDateTime = createdAt,
|
||||
): OutboxEventEntity {
|
||||
return OutboxEventEntity(
|
||||
id = UUID.randomUUID(),
|
||||
@@ -104,6 +162,8 @@ class OutboxEventRepositoryJpaTest @Autowired constructor(
|
||||
payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""),
|
||||
status = status,
|
||||
createdAt = createdAt,
|
||||
attempts = attempts,
|
||||
nextAttemptAt = nextAttemptAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package org.nstart.dep265.requestservice.repository
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
|
||||
import org.nstart.dep265.requestservice.entity.RequestEntity
|
||||
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||
import org.nstart.dep265.requestservice.entity.RequestSurveyType
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@SpringBootTest(
|
||||
classes = [PcpRequestServiceApplication::class],
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
||||
properties = [
|
||||
"spring.config.import=",
|
||||
"spring.cloud.config.enabled=false",
|
||||
"spring.flyway.enabled=false",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"spring.datasource.url=jdbc:h2:mem:request-expiry;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
|
||||
"spring.kafka.listener.auto-startup=false",
|
||||
"spring.kafka.consumer.group-id=request-service-test",
|
||||
"app.kafka.topics.route=request-service-test-route",
|
||||
],
|
||||
)
|
||||
class RequestRepositoryJpaTest @Autowired constructor(
|
||||
private val requestRepository: RequestRepository,
|
||||
) {
|
||||
private val now: LocalDateTime = LocalDateTime.parse("2026-06-06T12:00:00")
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
requestRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
fun `expirable query returns only overdue accepted and active requests`() {
|
||||
val overdueAccepted = request(status = RequestStatus.ACCEPTED, end = now.minusHours(1))
|
||||
val overdueActive = request(status = RequestStatus.ACTIVE, end = now.minusHours(2))
|
||||
val activeInWindow = request(status = RequestStatus.ACTIVE, end = now.plusHours(1))
|
||||
val alreadyCompleted = request(status = RequestStatus.COMPLETED, end = now.minusHours(1))
|
||||
val alreadyExpired = request(status = RequestStatus.EXPIRED, end = now.minusHours(1))
|
||||
val deletedOverdue = request(
|
||||
status = RequestStatus.DELETED,
|
||||
end = now.minusHours(1),
|
||||
deletedAt = now.minusMinutes(30),
|
||||
)
|
||||
requestRepository.saveAllAndFlush(
|
||||
listOf(overdueAccepted, overdueActive, activeInWindow, alreadyCompleted, alreadyExpired, deletedOverdue),
|
||||
)
|
||||
|
||||
val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 100)
|
||||
|
||||
// Только незавершённые (ACCEPTED/ACTIVE) с истёкшим окном и без soft-delete, по возрастанию end.
|
||||
assertEquals(listOf(overdueActive.id, overdueAccepted.id), expirable.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
fun `expirable query honours the batch size limit`() {
|
||||
val oldest = request(status = RequestStatus.ACTIVE, end = now.minusHours(3))
|
||||
val middle = request(status = RequestStatus.ACCEPTED, end = now.minusHours(2))
|
||||
val newest = request(status = RequestStatus.ACTIVE, end = now.minusHours(1))
|
||||
requestRepository.saveAllAndFlush(listOf(newest, oldest, middle))
|
||||
|
||||
val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 2)
|
||||
|
||||
assertEquals(listOf(oldest.id, middle.id), expirable.map { it.id })
|
||||
}
|
||||
|
||||
private fun request(
|
||||
status: RequestStatus,
|
||||
end: LocalDateTime,
|
||||
begin: LocalDateTime = end.minusDays(1),
|
||||
deletedAt: LocalDateTime? = null,
|
||||
): RequestEntity {
|
||||
val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"
|
||||
return RequestEntity(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Тест1",
|
||||
geometry = geometry,
|
||||
remainingGeometry = geometry,
|
||||
geometryArea = 1.0,
|
||||
remainingArea = 1.0,
|
||||
importance = 10.0,
|
||||
beginDateTime = begin,
|
||||
endDateTime = end,
|
||||
surveyType = RequestSurveyType.OPTICS,
|
||||
status = status,
|
||||
createdAt = begin.minusDays(1),
|
||||
updatedAt = begin.minusDays(1),
|
||||
deletedAt = deletedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
+67
-19
@@ -6,6 +6,8 @@ import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.doAnswer
|
||||
@@ -30,13 +32,16 @@ import java.util.concurrent.CompletableFuture
|
||||
class OutboxPublisherServiceTest {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
private val properties = OutboxProperties(
|
||||
requestCompletedTopic = "pcp.request.completed.v1",
|
||||
requestStatusTopic = "pcp.request.status.v1",
|
||||
publishBatchSize = 50,
|
||||
publishFixedDelayMs = 5_000,
|
||||
maxAttempts = 10,
|
||||
initialBackoffMs = 1_000,
|
||||
maxBackoffMs = 60_000,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `new request completed event is published and marked as published`() {
|
||||
fun `new terminal event is published and marked as published`() {
|
||||
val requestId = UUID.fromString("6cc8ed4b-0db1-4cfb-bef0-d56eed2edce4")
|
||||
val event = outboxEvent(
|
||||
requestId = requestId,
|
||||
@@ -50,7 +55,7 @@ class OutboxPublisherServiceTest {
|
||||
|
||||
assertEquals(1, processedCount)
|
||||
verify(kafkaTemplate).send(
|
||||
properties.requestCompletedTopic,
|
||||
properties.requestStatusTopic,
|
||||
requestId.toString(),
|
||||
event.payload.toString(),
|
||||
)
|
||||
@@ -61,11 +66,12 @@ class OutboxPublisherServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed request completed event is picked for retry`() {
|
||||
fun `failed event is picked for retry`() {
|
||||
val requestId = UUID.fromString("9986e096-886a-429e-af7f-3a6178c29b4f")
|
||||
val event = outboxEvent(
|
||||
requestId = requestId,
|
||||
status = OutboxEventStatus.FAILED,
|
||||
attempts = 1,
|
||||
errorMessage = "previous failure",
|
||||
)
|
||||
val outboxEventRepository = repositoryReturning(listOf(event))
|
||||
@@ -75,7 +81,7 @@ class OutboxPublisherServiceTest {
|
||||
service.publishPending()
|
||||
|
||||
verify(kafkaTemplate).send(
|
||||
properties.requestCompletedTopic,
|
||||
properties.requestStatusTopic,
|
||||
requestId.toString(),
|
||||
event.payload.toString(),
|
||||
)
|
||||
@@ -95,14 +101,14 @@ class OutboxPublisherServiceTest {
|
||||
service.publishPending()
|
||||
|
||||
verify(kafkaTemplate).send(
|
||||
properties.requestCompletedTopic,
|
||||
properties.requestStatusTopic,
|
||||
requestId.toString(),
|
||||
objectMapper.readTree(savedPayload).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kafka failure marks event as failed and stores short error message`() {
|
||||
fun `kafka failure increments attempts, schedules backoff and stores short error message`() {
|
||||
val requestId = UUID.fromString("116eb4c7-cd6d-4837-8525-21c1d16ecbbb")
|
||||
val event = outboxEvent(requestId = requestId)
|
||||
val outboxEventRepository = repositoryReturning(listOf(event))
|
||||
@@ -113,17 +119,42 @@ class OutboxPublisherServiceTest {
|
||||
|
||||
assertEquals(1, processedCount)
|
||||
assertEquals(OutboxEventStatus.FAILED, event.status)
|
||||
assertEquals(1, event.attempts)
|
||||
assertTrue(event.errorMessage!!.contains("broker unavailable"))
|
||||
assertNull(event.publishedAt)
|
||||
// backoff: следующая попытка отложена в будущее относительно создания события.
|
||||
assertTrue(event.nextAttemptAt.isAfter(event.createdAt))
|
||||
verify(outboxEventRepository).save(event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event exhausting max attempts becomes terminal and is not rescheduled`() {
|
||||
val requestId = UUID.fromString("c0ffee00-0000-4000-8000-000000000001")
|
||||
val event = outboxEvent(
|
||||
requestId = requestId,
|
||||
status = OutboxEventStatus.FAILED,
|
||||
attempts = properties.maxAttempts - 1,
|
||||
errorMessage = "previous failure",
|
||||
)
|
||||
val outboxEventRepository = repositoryReturning(listOf(event))
|
||||
val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("still broken")))
|
||||
val service = publisherService(outboxEventRepository, kafkaTemplate)
|
||||
|
||||
service.publishPending()
|
||||
|
||||
assertEquals(OutboxEventStatus.FAILED, event.status)
|
||||
assertEquals(properties.maxAttempts, event.attempts)
|
||||
// Исчерпав попытки, событие терминально: backoff не назначается (next_attempt_at не двигаем).
|
||||
assertEquals(event.createdAt, event.nextAttemptAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed event is not retried repeatedly in same publish pending call`() {
|
||||
val requestId = UUID.fromString("f31992a5-efae-4d9a-8262-78381da835f7")
|
||||
val event = outboxEvent(
|
||||
requestId = requestId,
|
||||
status = OutboxEventStatus.FAILED,
|
||||
attempts = 1,
|
||||
errorMessage = "previous failure",
|
||||
)
|
||||
val outboxEventRepository = repositoryReturning(listOf(event))
|
||||
@@ -134,15 +165,18 @@ class OutboxPublisherServiceTest {
|
||||
|
||||
assertEquals(1, processedCount)
|
||||
verify(kafkaTemplate, times(1)).send(
|
||||
properties.requestCompletedTopic,
|
||||
properties.requestStatusTopic,
|
||||
requestId.toString(),
|
||||
event.payload.toString(),
|
||||
)
|
||||
verify(outboxEventRepository, times(1)).findRequestCompletedPublishableForUpdateSkipLocked(
|
||||
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
|
||||
properties.publishBatchSize,
|
||||
verify(outboxEventRepository, times(1)).findPublishableForUpdateSkipLocked(
|
||||
anyString(),
|
||||
anyLocalDateTime(),
|
||||
anyInt(),
|
||||
anyInt(),
|
||||
)
|
||||
assertEquals(OutboxEventStatus.FAILED, event.status)
|
||||
assertEquals(2, event.attempts)
|
||||
assertTrue(event.errorMessage!!.contains("still broken"))
|
||||
}
|
||||
|
||||
@@ -153,6 +187,7 @@ class OutboxPublisherServiceTest {
|
||||
val failedEvent = outboxEvent(
|
||||
requestId = failedRequestId,
|
||||
status = OutboxEventStatus.FAILED,
|
||||
attempts = 1,
|
||||
errorMessage = "previous failure",
|
||||
)
|
||||
val newEvent = outboxEvent(requestId = newRequestId)
|
||||
@@ -164,12 +199,12 @@ class OutboxPublisherServiceTest {
|
||||
|
||||
assertEquals(2, processedCount)
|
||||
verify(kafkaTemplate).send(
|
||||
properties.requestCompletedTopic,
|
||||
properties.requestStatusTopic,
|
||||
failedRequestId.toString(),
|
||||
failedEvent.payload.toString(),
|
||||
)
|
||||
verify(kafkaTemplate).send(
|
||||
properties.requestCompletedTopic,
|
||||
properties.requestStatusTopic,
|
||||
newRequestId.toString(),
|
||||
newEvent.payload.toString(),
|
||||
)
|
||||
@@ -206,9 +241,11 @@ class OutboxPublisherServiceTest {
|
||||
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||
doReturn(events)
|
||||
.`when`(outboxEventRepository)
|
||||
.findRequestCompletedPublishableForUpdateSkipLocked(
|
||||
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
|
||||
properties.publishBatchSize,
|
||||
.findPublishableForUpdateSkipLocked(
|
||||
anyString(),
|
||||
anyLocalDateTime(),
|
||||
anyInt(),
|
||||
anyInt(),
|
||||
)
|
||||
return outboxEventRepository
|
||||
}
|
||||
@@ -256,20 +293,31 @@ class OutboxPublisherServiceTest {
|
||||
requestId: UUID,
|
||||
payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""",
|
||||
status: OutboxEventStatus = OutboxEventStatus.NEW,
|
||||
attempts: Int = 0,
|
||||
errorMessage: String? = null,
|
||||
): OutboxEventEntity {
|
||||
val createdAt = LocalDateTime.parse("2026-01-01T10:00:00")
|
||||
return OutboxEventEntity(
|
||||
id = UUID.randomUUID(),
|
||||
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
|
||||
aggregateType = RequestCompletedOutboxService.REQUEST_AGGREGATE_TYPE,
|
||||
eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE,
|
||||
aggregateType = RequestStatusOutboxService.REQUEST_AGGREGATE_TYPE,
|
||||
aggregateId = requestId,
|
||||
payload = objectMapper.readTree(payload),
|
||||
status = status,
|
||||
createdAt = LocalDateTime.parse("2026-01-01T10:00:00"),
|
||||
createdAt = createdAt,
|
||||
attempts = attempts,
|
||||
nextAttemptAt = createdAt,
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
}
|
||||
|
||||
// Регистрирует matcher, но возвращает non-null заглушку (any() на non-null LocalDateTime в Kotlin
|
||||
// вернул бы null и сломал стек матчеров Mockito).
|
||||
private fun anyLocalDateTime(): LocalDateTime {
|
||||
any(LocalDateTime::class.java)
|
||||
return LocalDateTime.now()
|
||||
}
|
||||
|
||||
private class ImmediateTransactionOperations : TransactionOperations {
|
||||
override fun <T : Any?> execute(action: TransactionCallback<T>): T {
|
||||
return action.doInTransaction(SimpleTransactionStatus())
|
||||
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
package org.nstart.dep265.requestservice.service
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.mockingDetails
|
||||
import org.nstart.dep265.requestservice.entity.RequestEntity
|
||||
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||
import org.nstart.dep265.requestservice.entity.RequestSurveyType
|
||||
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
class RequestCompletedOutboxServiceTest {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
.registerModule(JavaTimeModule())
|
||||
.findAndRegisterModules()
|
||||
|
||||
@Test
|
||||
fun `request completed payload contains required fields`() {
|
||||
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||
val service = RequestCompletedOutboxService(
|
||||
outboxEventRepository = outboxEventRepository,
|
||||
objectMapper = objectMapper,
|
||||
geometryService = GeometryService(),
|
||||
)
|
||||
val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846")
|
||||
val completedAt = LocalDateTime.parse("2026-01-01T10:15:30")
|
||||
val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00")
|
||||
val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00")
|
||||
val request = RequestEntity(
|
||||
id = requestId,
|
||||
name = "Тест1",
|
||||
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
remainingGeometry = "POLYGON EMPTY",
|
||||
geometryArea = 1.0,
|
||||
remainingArea = 0.0,
|
||||
importance = 10.0,
|
||||
beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
|
||||
endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"),
|
||||
surveyType = RequestSurveyType.OPTICS,
|
||||
status = RequestStatus.COMPLETED,
|
||||
lastMatchedAt = matchedAt,
|
||||
completedAt = completedAt,
|
||||
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
|
||||
updatedAt = completedAt,
|
||||
)
|
||||
|
||||
service.createRequestCompletedIfAbsent(request, eventCreatedAt)
|
||||
|
||||
val insertInvocation = mockingDetails(outboxEventRepository).invocations.single {
|
||||
invocation -> invocation.method.name == "insertIfAbsent"
|
||||
}
|
||||
val payload = objectMapper.readTree(insertInvocation.arguments[4] as String)
|
||||
assertEquals(requestId.toString(), payload["requestId"].asText())
|
||||
assertEquals("COMPLETED", payload["status"].asText())
|
||||
assertNotNull(payload["completedAt"])
|
||||
assertEquals(100.0, payload["coveragePercent"].asDouble())
|
||||
assertNotNull(payload["matchedAt"])
|
||||
assertNotNull(payload["eventCreatedAt"])
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package org.nstart.dep265.requestservice.service
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.mockingDetails
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.verifyNoInteractions
|
||||
import org.nstart.dep265.requestservice.config.RequestExpiryProperties
|
||||
import org.nstart.dep265.requestservice.entity.RequestEntity
|
||||
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||
import org.nstart.dep265.requestservice.entity.RequestSurveyType
|
||||
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
|
||||
import org.nstart.dep265.requestservice.repository.RequestRepository
|
||||
import org.springframework.transaction.TransactionStatus
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus
|
||||
import org.springframework.transaction.support.TransactionCallback
|
||||
import org.springframework.transaction.support.TransactionOperations
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
class RequestExpiryServiceTest {
|
||||
|
||||
@Test
|
||||
fun `expires overdue accepted and active requests and emits terminal events`() {
|
||||
val requestRepository = mock(RequestRepository::class.java)
|
||||
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
|
||||
|
||||
val expiredAccepted = overdueRequest(status = RequestStatus.ACCEPTED)
|
||||
val expiredActive = overdueRequest(status = RequestStatus.ACTIVE)
|
||||
doReturn(listOf(expiredAccepted, expiredActive))
|
||||
.`when`(requestRepository)
|
||||
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||
|
||||
val expiredCount = service.expireOverdue()
|
||||
|
||||
assertEquals(2, expiredCount)
|
||||
assertEquals(RequestStatus.EXPIRED, expiredAccepted.status)
|
||||
assertEquals(RequestStatus.EXPIRED, expiredActive.status)
|
||||
verify(requestRepository).save(expiredAccepted)
|
||||
verify(requestRepository).save(expiredActive)
|
||||
|
||||
// По каждой истёкшей заявке — терминальное событие в outbox для geoportal.
|
||||
val terminalInserts = mockingDetails(outboxEventRepository).invocations
|
||||
.filter { invocation -> invocation.method.name == "insertIfAbsent" }
|
||||
assertEquals(2, terminalInserts.size)
|
||||
assertTrue(terminalInserts.all { invocation -> invocation.arguments[1] == "REQUEST_TERMINAL_STATUS" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps fetching while a full batch is returned`() {
|
||||
val requestRepository = mock(RequestRepository::class.java)
|
||||
val service = expiryService(
|
||||
requestRepository,
|
||||
mock(OutboxEventRepository::class.java),
|
||||
RequestExpiryProperties(batchSize = 1),
|
||||
)
|
||||
|
||||
val first = overdueRequest(status = RequestStatus.ACTIVE)
|
||||
val second = overdueRequest(status = RequestStatus.ACCEPTED)
|
||||
doReturn(listOf(first), listOf(second), emptyList<RequestEntity>())
|
||||
.`when`(requestRepository)
|
||||
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||
|
||||
val expiredCount = service.expireOverdue()
|
||||
|
||||
assertEquals(2, expiredCount)
|
||||
verify(requestRepository, times(3))
|
||||
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty sweep returns zero and emits nothing`() {
|
||||
val requestRepository = mock(RequestRepository::class.java)
|
||||
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
|
||||
|
||||
doReturn(emptyList<RequestEntity>())
|
||||
.`when`(requestRepository)
|
||||
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||
|
||||
val expiredCount = service.expireOverdue()
|
||||
|
||||
assertEquals(0, expiredCount)
|
||||
verify(requestRepository, never()).save(any(RequestEntity::class.java))
|
||||
verifyNoInteractions(outboxEventRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request still inside its window is not expired`() {
|
||||
val requestRepository = mock(RequestRepository::class.java)
|
||||
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
val inWindow = request(
|
||||
status = RequestStatus.ACTIVE,
|
||||
beginDateTime = now.minusDays(1),
|
||||
endDateTime = now.plusDays(1),
|
||||
)
|
||||
doReturn(listOf(inWindow))
|
||||
.`when`(requestRepository)
|
||||
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||
|
||||
val expiredCount = service.expireOverdue()
|
||||
|
||||
assertEquals(0, expiredCount)
|
||||
assertEquals(RequestStatus.ACTIVE, inWindow.status)
|
||||
verify(requestRepository, never()).save(any(RequestEntity::class.java))
|
||||
verifyNoInteractions(outboxEventRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled scheduler does not sweep`() {
|
||||
val requestRepository = mock(RequestRepository::class.java)
|
||||
val service = expiryService(
|
||||
requestRepository,
|
||||
mock(OutboxEventRepository::class.java),
|
||||
RequestExpiryProperties(enabled = false),
|
||||
)
|
||||
|
||||
service.expireOverdueScheduledBatch()
|
||||
|
||||
verify(requestRepository, never())
|
||||
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||
}
|
||||
|
||||
private fun expiryService(
|
||||
requestRepository: RequestRepository,
|
||||
outboxEventRepository: OutboxEventRepository,
|
||||
properties: RequestExpiryProperties,
|
||||
): RequestExpiryService {
|
||||
val geometryService = GeometryService()
|
||||
return RequestExpiryService(
|
||||
requestRepository = requestRepository,
|
||||
requestLifecycleService = RequestLifecycleService(geometryService),
|
||||
requestStatusOutboxService = RequestStatusOutboxService(
|
||||
outboxEventRepository = outboxEventRepository,
|
||||
objectMapper = jacksonObjectMapper()
|
||||
.registerModule(JavaTimeModule())
|
||||
.findAndRegisterModules(),
|
||||
geometryService = geometryService,
|
||||
),
|
||||
requestExpiryProperties = properties,
|
||||
transactionOperations = ImmediateTransactionOperations(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun overdueRequest(status: RequestStatus): RequestEntity {
|
||||
val now = LocalDateTime.now()
|
||||
return request(
|
||||
status = status,
|
||||
beginDateTime = now.minusDays(10),
|
||||
endDateTime = now.minusDays(1),
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(
|
||||
status: RequestStatus,
|
||||
beginDateTime: LocalDateTime,
|
||||
endDateTime: LocalDateTime,
|
||||
): RequestEntity {
|
||||
val geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
return RequestEntity(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Тест1",
|
||||
geometry = geometry,
|
||||
remainingGeometry = geometry,
|
||||
geometryArea = 1.0,
|
||||
remainingArea = 1.0,
|
||||
importance = 10.0,
|
||||
beginDateTime = beginDateTime,
|
||||
endDateTime = endDateTime,
|
||||
surveyType = RequestSurveyType.OPTICS,
|
||||
status = status,
|
||||
createdAt = beginDateTime.minusDays(1),
|
||||
updatedAt = beginDateTime.minusDays(1),
|
||||
)
|
||||
}
|
||||
|
||||
// Регистрирует matcher, но возвращает non-null заглушку — иначе any() на non-null LocalDateTime
|
||||
// в Kotlin отдаёт null и ломает стек матчеров Mockito.
|
||||
private fun anyLocalDateTime(): LocalDateTime {
|
||||
any(LocalDateTime::class.java)
|
||||
return LocalDateTime.now()
|
||||
}
|
||||
|
||||
private class ImmediateTransactionOperations : TransactionOperations {
|
||||
override fun <T> execute(action: TransactionCallback<T>): T {
|
||||
val status: TransactionStatus = SimpleTransactionStatus()
|
||||
return action.doInTransaction(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -50,6 +50,7 @@ class RequestServiceTest {
|
||||
requestCellRepository = requestCellRepository,
|
||||
geometryService = GeometryService(),
|
||||
requestGridProjectionService = requestGridProjectionService,
|
||||
requestLifecycleService = RequestLifecycleService(GeometryService()),
|
||||
entityManager = entityManager,
|
||||
)
|
||||
|
||||
@@ -95,6 +96,22 @@ class RequestServiceTest {
|
||||
verify(requestGridProjectionService).rebuildProjection(persistedRequests.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create request passes through explicit coverage required percent from api`() {
|
||||
val persistedRequests = mutableListOf<RequestEntity>()
|
||||
doReturn(false)
|
||||
.`when`(requestRepository)
|
||||
.existsById(REQUEST_ID)
|
||||
doAnswer { invocation ->
|
||||
persistedRequests += invocation.getArgument<RequestEntity>(0)
|
||||
null
|
||||
}.`when`(entityManager).persist(any(RequestEntity::class.java))
|
||||
|
||||
service.createRequest(createRequestDto(coverageRequiredPercent = 80.0))
|
||||
|
||||
assertEquals(80.0, persistedRequests.single().coverageRequiredPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get request by id returns name and importance`() {
|
||||
val request = existingRequest()
|
||||
@@ -279,7 +296,7 @@ class RequestServiceTest {
|
||||
verify(requestCellRepository).deleteByRequestId(REQUEST_ID)
|
||||
}
|
||||
|
||||
private fun createRequestDto(): CreateRequestDto {
|
||||
private fun createRequestDto(coverageRequiredPercent: Double = 100.0): CreateRequestDto {
|
||||
return CreateRequestDto(
|
||||
id = REQUEST_ID,
|
||||
name = REQUEST_NAME,
|
||||
@@ -287,6 +304,7 @@ class RequestServiceTest {
|
||||
importance = REQUEST_IMPORTANCE,
|
||||
beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
|
||||
endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
|
||||
coverageRequiredPercent = coverageRequiredPercent,
|
||||
optics = OpticsParamsDto(
|
||||
resultType = OpticsResultTypeDto.PANCHROMATIC,
|
||||
resolution = 1.0,
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package org.nstart.dep265.requestservice.service
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.mockingDetails
|
||||
import org.nstart.dep265.requestservice.entity.RequestEntity
|
||||
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||
import org.nstart.dep265.requestservice.entity.RequestSurveyType
|
||||
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
class RequestStatusOutboxServiceTest {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
.registerModule(JavaTimeModule())
|
||||
.findAndRegisterModules()
|
||||
|
||||
@Test
|
||||
fun `completed terminal payload contains required fields`() {
|
||||
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||
val service = service(outboxEventRepository)
|
||||
val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846")
|
||||
val completedAt = LocalDateTime.parse("2026-01-01T10:15:30")
|
||||
val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00")
|
||||
val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00")
|
||||
val request = RequestEntity(
|
||||
id = requestId,
|
||||
name = "Тест1",
|
||||
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
remainingGeometry = "POLYGON EMPTY",
|
||||
geometryArea = 1.0,
|
||||
remainingArea = 0.0,
|
||||
importance = 10.0,
|
||||
beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
|
||||
endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"),
|
||||
surveyType = RequestSurveyType.OPTICS,
|
||||
status = RequestStatus.COMPLETED,
|
||||
lastMatchedAt = matchedAt,
|
||||
completedAt = completedAt,
|
||||
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
|
||||
updatedAt = completedAt,
|
||||
)
|
||||
|
||||
service.createTerminalIfAbsent(request, eventCreatedAt)
|
||||
|
||||
val payload = objectMapper.readTree(insertedPayload(outboxEventRepository))
|
||||
assertEquals(requestId.toString(), payload["requestId"].asText())
|
||||
assertEquals("COMPLETED", payload["status"].asText())
|
||||
assertEquals(completedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java))
|
||||
assertEquals(100.0, payload["coveragePercent"].asDouble())
|
||||
assertNotNull(payload["matchedAt"])
|
||||
assertNotNull(payload["eventCreatedAt"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired terminal payload carries expired status and partial coverage`() {
|
||||
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||
val service = service(outboxEventRepository)
|
||||
val requestId = UUID.fromString("9c2f0c4b-3a51-4e2d-9d8a-1f4b2c6e7a01")
|
||||
val eventCreatedAt = LocalDateTime.parse("2026-02-01T00:00:00")
|
||||
val request = RequestEntity(
|
||||
id = requestId,
|
||||
name = "Тест1",
|
||||
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
remainingGeometry = "POLYGON((0 0, 0.5 0, 0.5 1, 0 1, 0 0))",
|
||||
geometryArea = 1.0,
|
||||
remainingArea = 0.5,
|
||||
importance = 10.0,
|
||||
beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"),
|
||||
endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"),
|
||||
surveyType = RequestSurveyType.OPTICS,
|
||||
status = RequestStatus.EXPIRED,
|
||||
completedAt = null,
|
||||
createdAt = LocalDateTime.parse("2025-12-31T00:00:00"),
|
||||
updatedAt = eventCreatedAt,
|
||||
)
|
||||
|
||||
service.createTerminalIfAbsent(request, eventCreatedAt)
|
||||
|
||||
val payload = objectMapper.readTree(insertedPayload(outboxEventRepository))
|
||||
assertEquals("EXPIRED", payload["status"].asText())
|
||||
// completedAt отсутствует у EXPIRED → occurredAt = момент фиксации истечения.
|
||||
assertEquals(eventCreatedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java))
|
||||
assertEquals(50.0, payload["coveragePercent"].asDouble())
|
||||
assertTrue(payload["matchedAt"].isNull)
|
||||
}
|
||||
|
||||
private fun service(outboxEventRepository: OutboxEventRepository): RequestStatusOutboxService {
|
||||
return RequestStatusOutboxService(
|
||||
outboxEventRepository = outboxEventRepository,
|
||||
objectMapper = objectMapper,
|
||||
geometryService = GeometryService(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertedPayload(outboxEventRepository: OutboxEventRepository): String {
|
||||
val insertInvocation = mockingDetails(outboxEventRepository).invocations.single { invocation ->
|
||||
invocation.method.name == "insertIfAbsent"
|
||||
}
|
||||
return insertInvocation.arguments[4] as String
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -288,7 +288,7 @@ class RouteMatchingServiceTest {
|
||||
invocation -> invocation.method.name == "insertIfAbsent"
|
||||
}
|
||||
assertEquals(1, insertInvocations.size)
|
||||
assertEquals("REQUEST_COMPLETED", insertInvocations.single().arguments[1])
|
||||
assertEquals("REQUEST_TERMINAL_STATUS", insertInvocations.single().arguments[1])
|
||||
assertEquals(request.id, insertInvocations.single().arguments[3])
|
||||
assertEquals(RequestStatus.COMPLETED, request.status)
|
||||
}
|
||||
@@ -305,16 +305,16 @@ class RouteMatchingServiceTest {
|
||||
requestValidationService = RequestValidationService(geometryService),
|
||||
geometryService = geometryService,
|
||||
requestLifecycleService = RequestLifecycleService(geometryService),
|
||||
requestCompletedOutboxService = requestCompletedOutboxService(outboxEventRepository, geometryService),
|
||||
requestStatusOutboxService = requestStatusOutboxService(outboxEventRepository, geometryService),
|
||||
routeMatchingTransactionOperations = ImmediateTransactionOperations(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestCompletedOutboxService(
|
||||
private fun requestStatusOutboxService(
|
||||
outboxEventRepository: OutboxEventRepository,
|
||||
geometryService: GeometryService,
|
||||
): RequestCompletedOutboxService {
|
||||
return RequestCompletedOutboxService(
|
||||
): RequestStatusOutboxService {
|
||||
return RequestStatusOutboxService(
|
||||
outboxEventRepository = outboxEventRepository,
|
||||
objectMapper = jacksonObjectMapper()
|
||||
.registerModule(JavaTimeModule())
|
||||
|
||||
Reference in New Issue
Block a user