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:
Дмитрий Соловьев
2026-06-06 23:47:19 +03:00
parent 2499aebf2d
commit c1259c3105
33 changed files with 1600 additions and 139 deletions
+16 -5
View File
@@ -3,9 +3,18 @@ server:
pcp: pcp:
outbox: outbox:
request-completed-topic: pcp.request.completed.v1 request-status-topic: pcp.request.status.v1
publish-batch-size: 50 publish-batch-size: 50
publish-fixed-delay-ms: 5000 publish-fixed-delay-ms: 5000
max-attempts: 10
initial-backoff-ms: 1000
max-backoff-ms: 60000
request:
expiry:
enabled: true
fixed-delay-ms: 60000
batch-size: 200
max-batches-per-run: 50
spring: spring:
boot: boot:
@@ -60,10 +69,12 @@ logging:
app: app:
kafka: kafka:
topics: topics:
request: pcp.request.in.v1
route: pcp.route.georeference.v1 route: pcp.route.georeference.v1
request: consumer:
cleanup-retention-days: 30 route:
cleanup-cron: "0 0 3 * * *" dlq-topic: pcp.route.georeference-dlq.v1
max-attempts: 5
backoff-ms: 1000
max-backoff-ms: 30000
grid: grid:
enabled: true enabled: true
+4 -4
View File
@@ -14,7 +14,7 @@
| Сервис | Зона ответственности | REST base | Хранит | Kafka (in / out) | Зрелость* | | Сервис | Зона ответственности | REST base | Хранит | Kafka (in / out) | Зрелость* |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| **pcp-request-service** | Приём и жизненный цикл заявок на съёмку; сетка Земли (earth-grid), геопривязка ячеек, сопоставление маршрутов с заявками | `/v1/requests`, `/v1/cells`, `/v1/grid` | заявки, ячейки сетки, outbox | in: `pcp.route.georeference.v1` · out: `pcp.request.completed.v1` (outbox) | 🟢 высокая | | **pcp-request-service** | Приём и жизненный цикл заявок на съёмку; сетка Земли (earth-grid), геопривязка ячеек, сопоставление маршрутов с заявками | `/v1/requests`, `/v1/cells`, `/v1/grid` | заявки, ячейки сетки, outbox | in: `pcp.route.georeference.v1` · out: `pcp.request.status.v1` (outbox → geoportal) | 🟢 высокая |
| **pcp-route-processing-service** | Приём «паспортов маршрутов», нормализация/геопривязка, ретрансляция дальше; собственный outbox + DLQ | `/api/route-processing` (test) | outbox маршрутов | in: `pcp.request.survey-georeference.v1` · out: `pcp.route.georeference.v1`, `pcp.route.in.v1`, DLQ | 🟢 высокая | | **pcp-route-processing-service** | Приём «паспортов маршрутов», нормализация/геопривязка, ретрансляция дальше; собственный outbox + DLQ | `/api/route-processing` (test) | outbox маршрутов | in: `pcp.request.survey-georeference.v1` · out: `pcp.route.georeference.v1`, `pcp.route.in.v1`, DLQ | 🟢 высокая |
| **pcp-satellite-catalog-service** | Каталог КА (источник истины): спутники, группы, observation/slot-профили | `/api/satellites`, `/api/satellite-groups` | каталог КА, профили | out: `pcp.satellites` (`SatelliteDeletedEvent`) | 🟡 средняя (мало тестов) | | **pcp-satellite-catalog-service** | Каталог КА (источник истины): спутники, группы, observation/slot-профили | `/api/satellites`, `/api/satellite-groups` | каталог КА, профили | out: `pcp.satellites` (`SatelliteDeletedEvent`) | 🟡 средняя (мало тестов) |
| **pcp-ballistics-service** | Баллистика: орбиты, ЗРВ/RVA, узлы, трассы, time-extract по TLE | `/api/satellites`, `/api/obj-view`, `/api/tle`, `/api/rva` | орбитальные данные | in: `pcp.tle` (IC/ICRV/TLE фильтры), `pcp.satellites` | 🟡 средняя | | **pcp-ballistics-service** | Баллистика: орбиты, ЗРВ/RVA, узлы, трассы, time-extract по TLE | `/api/satellites`, `/api/obj-view`, `/api/tle`, `/api/rva` | орбитальные данные | in: `pcp.tle` (IC/ICRV/TLE фильтры), `pcp.satellites` | 🟡 средняя |
@@ -89,7 +89,7 @@ flowchart LR
EXT[Внешний источник / UI ⚠] -->|pcp.request.survey-georeference.v1| RP[route-processing] EXT[Внешний источник / UI ⚠] -->|pcp.request.survey-georeference.v1| RP[route-processing]
RP -->|pcp.route.georeference.v1| REQ[request-service] RP -->|pcp.route.georeference.v1| REQ[request-service]
RP -->|pcp.route.in.v1| MP[mission-planing] RP -->|pcp.route.in.v1| MP[mission-planing]
REQ -->|pcp.request.completed.v1 ⚠ потребитель| Q1{{?}} REQ -->|pcp.request.status.v1 · COMPLETED/EXPIRED| GEO[geoportal · внешний]
CAT[satellite-catalog] -->|pcp.satellites / SatelliteDeletedEvent| BAL[ballistics] CAT[satellite-catalog] -->|pcp.satellites / SatelliteDeletedEvent| BAL[ballistics]
CAT --> TLEM[tle-monitoring] CAT --> TLEM[tle-monitoring]
@@ -175,7 +175,7 @@ sequenceDiagram
| `pcp.route.georeference.v1` | route-processing | request-service | `RoutePassportDto` (геопривязанный) | ⚠ | | `pcp.route.georeference.v1` | route-processing | request-service | `RoutePassportDto` (геопривязанный) | ⚠ |
| `pcp.route.in.v1` | route-processing | mission-planing (`ModeStatusChangedEvent`) | processed route / mode-status | ⚠ | | `pcp.route.in.v1` | route-processing | mission-planing (`ModeStatusChangedEvent`) | processed route / mode-status | ⚠ |
| `pcp.request.in.v1` | request-service / UI ⚠ | ⚠ требует уточнения | `RequestPlacedEvent` | ⚠ | | `pcp.request.in.v1` | request-service / UI ⚠ | ⚠ требует уточнения | `RequestPlacedEvent` | ⚠ |
| `pcp.request.completed.v1` | request-service (outbox) | ⚠ требует уточнения | RequestCompleted | `aggregateId` (UUID заявки) | | `pcp.request.status.v1` | request-service (outbox) | **geoportal**нешний) | `REQUEST_TERMINAL_STATUS` (COMPLETED/EXPIRED) | `aggregateId` (UUID заявки) |
| `pcp.slots.status.v1` | slots-service | mission-planing, slots-service (self) | `BookedSlotsStatusChangedEvent` | ⚠ | | `pcp.slots.status.v1` | slots-service | mission-planing, slots-service (self) | `BookedSlotsStatusChangedEvent` | ⚠ |
| `pcp.tgu.satellite-plan-decision.v1` | Mission Ops / КА ⚠ | tgu-service | `PlanDecisionMessage` (ACCEPTED/REJECTED, `attemptId`) | ⚠ | | `pcp.tgu.satellite-plan-decision.v1` | Mission Ops / КА ⚠ | tgu-service | `PlanDecisionMessage` (ACCEPTED/REJECTED, `attemptId`) | ⚠ |
| `pcp.tgu.visibility-windows-changed.v1` | tgu-service | tgu-service | `VisibilityWindowsChangedMessage` | ⚠ | | `pcp.tgu.visibility-windows-changed.v1` | tgu-service | tgu-service | `VisibilityWindowsChangedMessage` | ⚠ |
@@ -255,7 +255,7 @@ tgu : GET /api/plans/{spacecraftId} ; /api/platforms ;
Эти пункты помечены `⚠` выше — их стоит зафиксировать до изменений: Эти пункты помечены `⚠` выше — их стоит зафиксировать до изменений:
1. Producer топиков `pcp.request.survey-georeference.v1` и `pcp.request.in.v1` (внешняя система или UI?). 1. Producer топиков `pcp.request.survey-georeference.v1` и `pcp.request.in.v1` (внешняя система или UI?).
2. Consumer `pcp.request.completed.v1`кто слушает завершённые заявки (предположительно complex-mission/планирование). 2. ~~Consumer `pcp.request.completed.v1`~~уточнено: потребитель терминальных статусов заявки = внешний **geoportal**, топик переименован в `pcp.request.status.v1` (COMPLETED/EXPIRED, #35).
3. Producer `pcp.tgu.satellite-plan-decision.v1` — реальный Mission Ops/КА vs текущая имитация в `FlowService`. 3. Producer `pcp.tgu.satellite-plan-decision.v1` — реальный Mission Ops/КА vs текущая имитация в `FlowService`.
4. Точные DTO payload для `pcp.tle`, `pcp.route.in.v1`, `pcp.slots.status.v1` — зафиксировать схемы. 4. Точные DTO payload для `pcp.tle`, `pcp.route.in.v1`, `pcp.slots.status.v1` — зафиксировать схемы.
5. Возможная циклическая зависимость `complex-mission ⇄ coverage-scheme` в рантайме. 5. Возможная циклическая зависимость `complex-mission ⇄ coverage-scheme` в рантайме.
+2 -2
View File
@@ -55,7 +55,7 @@
| `pcp.route.georeference.v1` | `pcp-route-processing-service` (`route-georeference`) | `pcp-request-service` (`RequestKafkaListener.consumeRoute`) | геопривязка маршрута к заявке | | `pcp.route.georeference.v1` | `pcp-route-processing-service` (`route-georeference`) | `pcp-request-service` (`RequestKafkaListener.consumeRoute`) | геопривязка маршрута к заявке |
| `pcp.request.survey-georeference.v1` | (инжект `RouteProcessingTestController` / внешний источник) | `pcp-route-processing-service` (`RoutePassportMessageListener`, вход) | входной маршрутный паспорт на обработку | | `pcp.request.survey-georeference.v1` | (инжект `RouteProcessingTestController` / внешний источник) | `pcp-route-processing-service` (`RoutePassportMessageListener`, вход) | входной маршрутный паспорт на обработку |
| `pcp.request.survey-georeference-dlq.v1` | `pcp-route-processing-service` (DLQ) | — | dead-letter для входных паспортов | | `pcp.request.survey-georeference-dlq.v1` | `pcp-route-processing-service` (DLQ) | — | dead-letter для входных паспортов |
| `pcp.request.completed.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | *(в репозитории нет консьюмера)* | заявка завершена (REQUEST_COMPLETED) | | `pcp.request.status.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | **geoportal** (внешний, в репо нет) | терминальный статус заявки `COMPLETED`/`EXPIRED` (`REQUEST_TERMINAL_STATUS`) |
| `pcp.request.in.v1` | — | *(объявлен в конфиге `pcp-request-service`, активного слушателя нет)* | входящие заявки (legacy/зарезервировано) | | `pcp.request.in.v1` | — | *(объявлен в конфиге `pcp-request-service`, активного слушателя нет)* | входящие заявки (legacy/зарезервировано) |
### Поток данных (упрощённо) ### Поток данных (упрощённо)
@@ -76,7 +76,7 @@ route-processing ──pcp.route.georeference.v1──> request-service
(вход) pcp.request.survey-georeference.v1 ──> route-processing (вход) pcp.request.survey-georeference.v1 ──> route-processing
mission-planing ──pcp.slots.status.v1(BookedSlots)──────> slots-service mission-planing ──pcp.slots.status.v1(BookedSlots)──────> slots-service
request-service ──pcp.request.completed.v1 (outbox)─────> (нет консьюмера в репо) request-service ──pcp.request.status.v1 (outbox)────────> geoportal (внешний)
``` ```
### ТГУ-топики (`pcp.tgu.*`) ### ТГУ-топики (`pcp.tgu.*`)
@@ -0,0 +1,310 @@
# Аудит сервиса заданий — pcp-request-service
Дата: 2026-06-06. Автор: Claude (Opus 4.8).
Охват: сервис `pcp-request-service` («сервис заданий») — задача [#26](бэклог-pcp-tgu-ops-ui.md).
Метод: статический разбор кода (контроллеры, Kafka-listener, сервисы сопоставления и жизненного
цикла, outbox, earth-grid), схема БД и Flyway-миграция, конфиг в `config-repo`, обзор тестов.
Парные документы: общий [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md),
карты [PCP_ARCHITECTURE.md](architect/PCP_ARCHITECTURE.md) и
[PCP_SERVICE_INTERACTIONS.md](architect/PCP_SERVICE_INTERACTIONS.md).
Стык с сервисом паспорта маршрута (`pcp-route-processing-service`) — отдельная задача
[#28](бэклог-pcp-tgu-ops-ui.md), здесь только обозначен.
## Сводка по состоянию
Базовое здоровье — хорошее. Сервис держит жизненный цикл заявок на съёмку, ведёт учёт отснятой
территории геометрическим вычитанием и сопоставляет приходящие паспорта маршрутов с заявками.
Конкурентность и работа в нескольких инстансах продуманы: per-row пессимистичные блокировки,
`SKIP LOCKED` в outbox, advisory-lock на пересборку сетки, дедуп по уникальным ключам,
transactional outbox. 19 тестовых файлов с покрытием ключевых сервисов и репозиториев.
Главные риски — не в инфраструктуре, а в **бизнес-логике закрытия и сопоставления**:
закрытие «по времени» само по себе не срабатывает (нет планировщика истечения), а матчинг
засчитывает покрытие без проверки совместимости аппарата. Приоритеты: **RS1**, **RS2** (высокий),
далее RS3/RS4 (средний), затем долг RS5–RS7.
Порт 7005. БД `pcp_requests` (PostgreSQL + PostGIS), Flyway `V1`, `ddl-auto: validate`.
Пакет `org.nstart.dep265.requestservice` (несогласован с остальными — см. B3 общего аудита).
## Назначение и контур
Заявки создаются **только по HTTP**; паспорта маршрутов приходят **из Kafka**; завершение
заявки уходит наружу через **transactional outbox**.
```
HTTP (оператор / pcp-tgu-ops-ui через Vite-прокси)
POST/GET/DELETE /v1/requests · GET /v1/cells · /v1/grid
┌───────────────────────────────────┐
Kafka │ pcp-request-service │ Kafka
IN ───▶│ заявки · покрытие · lifecycle · │── OUT ──▶ pcp.request.status.v1 → geoportal
│ earth-grid · transactional outbox │ (outbox; COMPLETED/EXPIRED, #35)
└───────────────────────────────────┘
▲ │
│ ▼
pcp.route.georeference.v1 DB: pcp_requests (PostgreSQL + PostGIS)
pcp-route-processing-service ◀── связь для задачи #28
(«сервис паспорта маршрута»)
```
### Карта взаимодействий
| Направление | Канал | Контрагент | Примечание |
|---|---|---|---|
| IN (HTTP) | `/v1/requests` (POST/GET/`/map`/`/{id}`/`/{id}/cells`/DELETE) | UI / оператор | заявки заводятся только здесь |
| IN (HTTP) | `/v1/cells` (+`/priority-map`), `/v1/grid` (settings, `/rebuild`) | UI / оператор | сетка и карта важности |
| IN (Kafka) | `pcp.route.georeference.v1` | **pcp-route-processing-service** | `KafkaMessage<RoutePassportDto>`, контракт из `pcp-types-lib` |
| OUT (Kafka) | `pcp.request.status.v1` | **geoportal** (внешний) | outbox `REQUEST_TERMINAL_STATUS`; терминальный статус `COMPLETED`/`EXPIRED` в payload (#35) |
| — | `pcp.request.in.v1` (`app.kafka.topics.request`) | — | в конфиге есть, **слушателя нет** — мёртвый конфиг (RS4) |
| DB | `pcp_requests` (Postgres + PostGIS) | — | Flyway `V1`, `ddl-auto: validate` |
HTTP-поверхность: [RequestController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt),
[CellsController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsController.kt),
[GridManagementController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementController.kt).
## Доменная модель и БД
Источник: [V1__create_schema.sql](../services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql).
- **`requests`** — заявка/задание: `geometry` (исходная) + `remaining_geometry` (что осталось снять),
`geometry_area` / `remaining_area`, `coverage_required_percent`, окно `begin/end_date_time`,
`status`, `match_count`, `last_matched_at`, `completed_at`, мягкое удаление `deleted_at`,
`@Version version` (оптимистичная блокировка). Статусы CHECK-ограничением:
`ACCEPTED · ACTIVE · COMPLETED · EXPIRED · DELETED`.
- **`request_optics_params` / `request_rsa_params`** — параметры съёмки (разрешение, угол Солнца,
облачность / поляризация, интерферометрия). Хранятся, но в матчинге **не используются** (RS2).
- **`request_route_matches`** — аудит и идемпотентность сопоставления: `UNIQUE(request_id, route_id)`,
`original_intersection_*`, `applied_intersection_*`, `coverage_delta_percent`, `contributes_to_coverage`.
- **`outbox_events`** — transactional outbox: `UNIQUE(event_type, aggregate_id)`, статусы `NEW/PUBLISHED/FAILED`.
- **`earth_cells` / `request_cells` / `earth_grid_settings`** — сетка Земли и проекция заявок на неё;
PostGIS-триггеры заполняют `contour_geom` и пересчитывают `earth_cells.importance`
(= Σ `request_cell.importance × coverage_percent/100`). Это **карта важности**, не прогресс съёмки (RS6).
Геометрия хранится как WKT (`TEXT`); вычисления — JTS (`jts-core`), без hibernate-spatial.
## Учёт отснятой территории
Ядро — [RouteMatchingService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt),
геометрия — [GeometryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt).
На каждый паспорт маршрута:
1. Кандидаты выбираются по **окну времени + статусу** (`ACCEPTED`/`ACTIVE`, не удалённые) —
[findRequestIdsForRoute](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt).
2. Идемпотентность: `existsByRequestIdAndRouteId` + `request_route_matches UNIQUE` + обработка гонки вставки.
3. На каждую заявку — своя транзакция с `findByIdForUpdate` (row-lock): маршрут пересекается с
**`remaining_geometry`** (не с исходной), остаток уменьшается `difference`, пересчитывается `remaining_area`.
4. `coveredPercent = (geometry_area remaining_area) / geometry_area × 100`.
Поскольку учёт идёт по остатку, **перекрывающиеся маршруты не задваиваются** — второй маршрут
засчитывает только новую площадь. Это корректная кумулятивная модель покрытия.
Ограничения учёта вынесены в находки **RS2** (игнор совместимости аппарата), **RS5** (планарная
площадь в градусах), **RS6** (earth-grid — это важность, а не покрытие).
## Жизненный цикл и закрытие заданий
Вся логика — пересчёт статуса «с нуля» в
[RequestLifecycleService.refreshStatus](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt),
по приоритету сверху вниз:
| Условие | Статус |
|---|---|
| `coveredPercent ≥ coverage_required_percent` | **COMPLETED** (ставится `completed_at`) — приоритет над сроком |
| иначе `now < begin_date_time` | ACCEPTED |
| иначе `now > end_date_time` | **EXPIRED** |
| иначе | ACTIVE |
При переходе в терминальный статус (`COMPLETED` или `EXPIRED`) — транзакционный outbox-эвент
`REQUEST_TERMINAL_STATUS` (идемпотентно по `(event_type, aggregate_id)`,
[RequestStatusOutboxService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt)),
который затем публикуется
[OutboxPublisherService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt)
в `pcp.request.status.v1` (geoportal). См. #35.
Ключевая особенность (бывший источник RS1): `refreshStatus` исторически вызывался **только из матчинга
маршрута**. После #29 его дополнительно дёргают планировщик истечения (`RequestExpiryService`) и
`createRequest`, поэтому закрытие «по времени» теперь срабатывает (см. RS1).
## Горизонтальное масштабирование (несколько инстансов)
Сервис **спроектирован под несколько одинаковых инстансов** — это сильная сторона.
Безопасно при N инстансов:
- **Матчинг** — `findByIdForUpdate` (`SELECT … FOR UPDATE`, пессимистичная блокировка строки заявки)
в отдельной транзакции на заявку (`PROPAGATION_REQUIRED` из не-транзакционного listener →
одна независимая транзакция на заявку), плюс `@Version` и дедуп `(request_id, route_id)`.
Инстансы делят партиции топика (consumer-group `pcp-request-service-route`).
- **Outbox** — `SELECT … FOR UPDATE SKIP LOCKED` + `UNIQUE(event_type, aggregate_id)`
параллельные инстансы не публикуют эвент дважды
([OutboxEventRepository](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt)).
- **Пересборка сетки** — `pg_try_advisory_xact_lock`
([GridRebuildLockService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildLockService.kt)) →
ребилдит только один инстанс.
- **Создание заявки** — `existsById` + PK + обработка `23505` → дубликат не пройдёт при гонке.
Семантика доставки — **at-least-once**; повторная обработка маршрута безопасна за счёт дедупа.
После #31 «ядовитое» сообщение уходит в DLQ (`DefaultErrorHandler` + bounded retry), а не блокирует
партицию. Outbox FAILED после #35 ретраится с backoff и пределом попыток. Параллелизм потребления
маршрутов ограничен числом партиций топика.
---
## Находки
### RS1 (высокий). Нет планировщика истечения — закрытие «по времени» не срабатывает — ✅ устранено (#29)
Файлы: [RequestLifecycleService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt),
[RouteMatchingService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt)
`refreshStatus` — единственный путь смены статуса — вызывается **только при сопоставлении маршрута**.
Единственный `@Scheduled` в сервисе — публикация outbox. Поэтому если по заявке после `end_date_time`
**не пришло ни одного маршрута**, она навсегда останется `ACTIVE`/`ACCEPTED` и **никогда не станет
`EXPIRED`**. В `EXPIRED` заявка попадает лишь побочно — если какой-то маршрут случайно пересечётся
с ней уже после истечения срока. Дополнительно: `createRequest` всегда ставит `ACCEPTED`, даже если
окно уже в прошлом (`refreshStatus` при создании не вызывается).
Рекомендация: периодический job (`@Scheduled` + advisory-lock или `SKIP LOCKED`-выборка), который
переводит просроченные заявки в `EXPIRED` без участия маршрутов; вызывать `refreshStatus` и при создании.
**Готово (#29):** добавлен [RequestExpiryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt) —
`@Scheduled`-воркер переводит просроченные `ACCEPTED`/`ACTIVE` в `EXPIRED`, забирая заявки батчами через
`findExpirableForUpdateSkipLocked` (`FOR UPDATE SKIP LOCKED` + верхний лимит батчей — безопасно при нескольких
инстансах). `refreshStatus` теперь вызывается и в `createRequest`, поэтому статус приёма отражает окно
(`ACCEPTED`/`ACTIVE`/`EXPIRED`). Конфиг — `pcp.request.expiry.*`. Тесты: `RequestExpiryServiceTest`,
`RequestRepositoryJpaTest`.
### RS2 (высокий). Матчинг игнорирует совместимость аппарата
Файлы: [RouteMatchingService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt),
[RequestValidationService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestValidationService.kt)
Кандидаты подбираются только по окну времени и статусу, а покрытие засчитывается по любому
геометрическому пересечению. `survey_type` (оптика/РСА), разрешение, угол Солнца, облачность и
**крен** в фильтрации **не участвуют**: крен парсится и валидируется (0–90°), но как фильтр не
используется; параметры съёмки лежат в БД мёртвым грузом. Следствие: РСА-маршрут «закроет»
оптическую заявку, маршрут с негодным разрешением/освещённостью засчитается как покрытие.
Перекликается с UI-задачей #4.
Рекомендация: добавить проверку совместимости маршрут↔заявка перед зачётом покрытия (как минимум
survey_type и крен; в идеале — разрешение и угол Солнца). Требует, чтобы паспорт маршрута нёс тип
сенсора — согласовать в рамках #28.
**Решение (2026-06-06): отложено до #28.** `RoutePassportDto` сейчас не несёт явный `surveyType`
(есть `polarisation`, `resolutionRange`/`Azimuth`, `rollAngle`, `visirAngle`). Фиксируем контракт
паспорта в рамках аудита связи сервисов #28 и тогда же добавляем фильтр совместимости. До этого
matching не трогаем.
### RS3 (средний). Нет DLQ / обработчика ошибок Kafka — poison-сообщение блокирует партицию — ✅ устранено (#31)
Файл: [RequestKafkaListener.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/kafka/RequestKafkaListener.kt)
Listener ловит исключение, логирует и **перебрасывает** его. DLQ и кастомный `CommonErrorHandler`
не настроены — работают дефолты Spring Kafka. Невалидное («ядовитое») сообщение маршрута будет
ретраиться бесконечно и **заблокирует партицию**. У парного `pcp-route-processing-service` DLQ есть,
здесь — нет.
Рекомендация: `DefaultErrorHandler` с ограниченными ретраями и `DeadLetterPublishingRecoverer`
(топик вида `pcp.route.georeference-dlq.v1`); невалидные по контракту сообщения отправлять в DLQ,
а не зацикливать.
**Готово (#31):** [KafkaConsumerConfig](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt) —
`DefaultErrorHandler` + `DeadLetterPublishingRecoverer` (DLQ `pcp.route.georeference-dlq.v1`),
экспоненциальный bounded retry; контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`)
не ретраятся, а сразу уходят в DLQ с диагностическими заголовками (`pcp-exception-class`,
`pcp-original-topic`/`-partition`/`-offset`). Настройки — `app.kafka.consumer.route.*`. По образцу
`pcp-route-processing-service`. Тест — `KafkaConsumerConfigTest`.
### RS4 (средний). Мёртвый конфиг: cleanup и входной топик заявок — ✅ устранено (#32)
Файл: [config-repo/pcp-request-service.yaml](../config-repo/pcp-request-service.yaml)
`app.request.cleanup-cron` / `cleanup-retention-days` были заданы, но **кода под них нет** — архивации
или удаления старых заявок не происходило. Аналогично `app.kafka.topics.request: pcp.request.in.v1`
был сконфигурирован, но **без слушателя** (заявки заводятся только по HTTP). Конфиг вводил в заблуждение.
Решение (#32): ключи удалены из `config-repo` (привязок `@Value` не было). Если cleanup/приём заявок
из Kafka понадобятся — заводить конфиг вместе с реализацией.
### RS5 (низкий). Планарная площадь в градусах вместо геодезической
Файл: [GeometryService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt)
Площади считаются JTS `geometry.area` в координатах EPSG:4326 (градусы). Для компактных заявок
процент покрытия как отношение почти корректен, но для крупных и высокоширотных областей даёт
систематическое искажение (градус долготы у полюса короче).
Рекомендация: при росте требований к точности — считать площадь геодезически (например, PostGIS
`ST_Area(geography)` или проекция в равновеликую СК), а не планарно в градусах.
**Решение (2026-06-06): отложено.** Покрытие считается отношением площадей, поэтому для обычных AOI
планарная оценка близка к геодезической. Переключение `area()` на геодезию ripple-эффектом затронет
учёт `coverageDeltaPercent` и точечные ассерты площадей в `RouteMatchingServiceTest`; делаем при
реальном росте требований к точности (крупные/высокоширотные заявки).
### RS6 (низкий). `coverage_required_percent` захардкожен в 100%; earth-grid ≠ покрытие — ✅ устранено (#34)
Файлы: [RequestService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt),
[RequestGridProjectionService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionService.kt)
`createRequest` всегда ставит `DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0`, игнорируя значение из API,
хотя колонка и поле его поддерживают. Отдельно стоит зафиксировать: `request_cells.coverage_percent`
и `/priority-map` — это **доля футпринта заявки в ячейке × важность** (карта приоритетов), считается
один раз при создании и **маршрутами не обновляется**. Реальный прогресс съёмки живёт только в
`remaining_geometry` / `remaining_area`. Это легко перепутать по названию поля.
Рекомендация: пробрасывать требуемый процент из API; в документации/именовании развести «важность
ячейки» и «прогресс покрытия».
**Готово (#34):** в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent`
(0 < x ≤ 100, по умолчанию 100); оно пробрасывается в сущность и служит порогом `COMPLETED`,
константа `DEFAULT_COVERAGE_REQUIRED_PERCENT` убрана. В KDoc разведены «прогресс покрытия»
(`coverageRequiredPercent`/`remaining_area`) и «важность заявки» (`importance` → карта приоритетов
ячеек). Тесты — `RequestServiceTest`, `RequestApiDtoContractTest`.
### RS7 (средний). Уведомление geoportal о финале заявки; outbox FAILED без backoff — ✅ устранено (#35)
Файлы: [OutboxPublisherService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt),
[OutboxEventRepository.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt),
[RequestStatusOutboxService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt)
Потребитель завершения заявок — внешний сервис **geoportal** (в этом монорепо его нет, поэтому в
[PCP_SERVICE_INTERACTIONS.md](architect/PCP_SERVICE_INTERACTIONS.md) консьюмер не виден). Ему нужно
сообщать про **оба** терминальных исхода: «выполнено» (`COMPLETED`) и «срок вышел» (`EXPIRED`). Сейчас
эмитится только `COMPLETED``pcp.request.completed.v1`), а `EXPIRED`-эвента нет вовсе. Плюс
публикуемая выборка берёт `NEW` + `FAILED`, поэтому FAILED **ретраится**, но без backoff и без cap:
при устойчивом сбое Kafka каждые 5 с все инстансы перевыбирают FAILED (нагрузка, но не потеря).
Решение: свести уведомления в единый топик **`pcp.request.status.v1`** (по аналогии с
`pcp.slots.status.v1`) с терминальным статусом в payload, заменив orphan `pcp.request.completed.v1`;
эмитить `EXPIRED`-эвент через тот же transactional outbox (зависит от планировщика истечения **RS1**,
который и создаёт переход в `EXPIRED`). Outbox дополнить `next_attempt_at`/экспоненциальным backoff и
пределом попыток — по образцу `RoutePassportOutboxWorker` из `pcp-route-processing-service`.
**Готово (#35):** единый топик `pcp.request.status.v1` с терминальным статусом в payload
(`COMPLETED`/`EXPIRED`, плюс `occurredAt`/`coveragePercent`) — заменил orphan `pcp.request.completed.v1`.
Единое событие `REQUEST_TERMINAL_STATUS` пишется в той же транзакции из двух мест:
[RouteMatchingService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt)
(по достижению покрытия) и [RequestExpiryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt)
(по истечению срока). Outbox получил `attempts`/`next_attempt_at`: при сбое — удвоение backoff от
`initial-backoff-ms` до `max-backoff-ms`, после `max-attempts` событие становится терминальным `FAILED`
и из выборки выпадает. Конфиг — `pcp.outbox.*`. Мелкий зазор: заявка, **созданная** уже просроченной,
терминальное событие не порождает (вырожденный кейс; обычные пути ACTIVE→COMPLETED/EXPIRED покрыты).
## Что в порядке (намеренно не отмечаем как проблемы)
- Конкурентность и мультиинстанс: row-lock на заявку, `SKIP LOCKED` в outbox, advisory-lock на сетку,
`@Version`, дедуп по уникальным ключам — сделано грамотно.
- Transactional outbox: терминальный эвент `REQUEST_TERMINAL_STATUS` пишется в той же транзакции, что и завершение/истечение заявки.
- Идемпотентность сопоставления: повтор маршрута безопасен (`request_route_matches UNIQUE` + проверки).
- Кумулятивный учёт покрытия по остатку (без задвоения перекрытий).
- Тесты: `RouteMatchingServiceTest`, `OutboxPublisherServiceTest`,
`RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RequestRepositoryJpaTest`, `MigrationSchemaTest`.
## Статус работ
Сделано:
-**RS1** — устранено (#29): планировщик истечения `RequestExpiryService` + `refreshStatus` при создании.
-**RS3** — устранено (#31): `DefaultErrorHandler` + DLQ `pcp.route.georeference-dlq.v1`.
-**RS4** — устранено (#32): мёртвый конфиг удалён из `config-repo`.
-**RS6** — устранено (#34): проброс `coverageRequiredPercent` из API + разведение терминов.
-**RS7** — устранено (#35): единый топик `pcp.request.status.v1` (COMPLETED/EXPIRED) + backoff/cap outbox.
Отложено (осознанно):
-**RS2** (#30) — фильтр совместимости аппарата. Отложено **до #28**: паспорт маршрута не несёт явный `surveyType`, контракт фиксируется в аудите связи сервисов.
-**RS5** (#33) — геодезическая площадь. Отложено до роста требований к точности (переключение `area()` затрагивает matching и тесты; покрытие — отношение площадей).
+41 -1
View File
@@ -10,7 +10,10 @@
У каждой задачи есть стабильный номер `#N` — он не меняется при правках списка, на него удобно ссылаться. Новые задачи получают следующий свободный номер. У каждой задачи есть стабильный номер `#N` — он не меняется при правках списка, на него удобно ссылаться. Новые задачи получают следующий свободный номер.
_Обновлено: 2026-06-05_ > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации
> по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C).
_Обновлено: 2026-06-06_
--- ---
@@ -31,12 +34,49 @@ _Обновлено: 2026-06-05_
### Бэкенд / инфраструктура ### Бэкенд / инфраструктура
- [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). - [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu).
### Проверка сервисов
- [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности.
- [ ] **#28** — Проверить связь сервиса заданий и сервиса получения паспорта маршрута между собой.
### Баги ### Баги
- [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы». - [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы».
### Аудит — надёжность и бэкенд
_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._
- [ ] **#12** — (B1, высокий) Camunda job-воркеры в `FlowService` молча глотают исключения (`catch { logger.warn(ex.message) }`) при `autoComplete=false`: задача не завершается и не падает, BPMN-процесс зависает без инцидента. Завершать через `newFailCommand`/проброс наверх, логировать `error(msg, ex)`.
- [ ] **#13** — (B2, средний) Снять симуляционную заглушку `Thread.sleep(5000)` («имитация подтверждения плана») в `FlowService.sendPlan` при появлении реального канала подтверждений.
- [ ] **#14** — (B3, средний) Привести в порядок несогласованные пакеты-копипасты (ballistics→`pcp_request_service`, complex-mission→`pcp_satellites_service`, ui-service и slots-service делят `slots_service`, request→`org.nstart.dep265`, tgu→без `.pcp.`). Отдельной задачей из-за широкого охвата.
- [ ] **#15** — (B4, низкий) Опечатка «srpring» в `pcp-srpring-boot-admin-server` растиражирована по каталогу, пакету, CI, docker-compose, gradle. Чинить вместе с #14.
- [ ] **#16** — (B5, низкий) ~9 мест `logger.warn/error(ex.message)` теряют stack trace — заменить на `logger.error("...", ex)`.
### Аудит — фронтенд
_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._
- [ ] **#17** — (F1, средний) Вынести общий `src/api/http.ts` (`fetchJson` + `extractErrorText` + маппинг ошибок): `extractErrorText` продублирован в 5 клиентах, fetch-обвязка повторяется ~22 раза. Заодно устранить расхождение приоритета полей ошибки (F5).
- [ ] **#18** — (F2, низкий) Перевести нетипизированный островок `features/constellation/*` (`.jsx`/`.js`, `allowJs`) на TS или добавить декларации типов на границе.
- [ ] **#19** — (F3, низкий) Удалить мёртвый barrel `features/constellation/index.js` (нигде не импортируется).
- [ ] **#20** — (F4, низкий) Декомпозировать God-компонент `TguEditorTab.tsx` (1727 строк): вынести хуки (`useDraftHistory`, `useRouteBuilder`), разнести панели.
### Аудит — гигиена репозитория и конфиги
_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._
- [ ] **#21** — (R1, средний) Убрать из git крупные дампы в корне (`pcp_requests_converted_create_requests.json` ~228 КБ, `response_заданияКубернетс.json` ~157 КБ, `response_заданияЕмельянов.json` ~66 КБ) — перенести в `src/test/resources`/`docs` или удалить с добавлением в `.gitignore`.
- [ ] **#22** — (R2, низкий) Удалить посторонние артефакты в корне: `node_modules/` + пустой `package-lock.json`, `obj_terra.zip`; при желании добавить `/package-lock.json` в `.gitignore`.
- [ ] **#23** — (R3, низкий) Прогнать `./gradlew clean` / удалить 26 устаревших каталогов `bin/` (задваивают исходники, шумят при grep).
- [ ] **#24** — (C1, средний) Унифицировать креды БД в config-repo: привести все сервисы к переопределяемому `password: ${DB_PASSWORD:...}` (как в tgu-service), убрать зашитые `password: password`.
- [ ] **#25** — (C2, низкий) Сузить CORS для прода в `pcp-ui-service` (`allowedOriginPatterns("*")` + `allowCredentials`) до конкретных origin; держать в курсе отсутствие CORS у напрямую вызываемых сервисов (работает только через прокси/nginx).
### Аудит — сервис заданий (pcp-request-service)
_Источник и рекомендации: [аудит-сервис-заданий.md](аудит-сервис-заданий.md) (коды находок RS1–RS7)._
- [ ] **#30** — (RS2, высокий) Матчинг засчитывает покрытие по любому геометрическому пересечению, игнорируя совместимость аппарата (`survey_type`, крен, разрешение, угол Солнца). Добавить фильтр совместимости маршрут↔заявка перед зачётом покрытия; тип сенсора в паспорте маршрута согласовать в рамках #28. _Отложено до #28 (решение, 2026-06-06): `RoutePassportDto` не несёт явный `surveyType` — контракт фиксируется в рамках аудита связи сервисов #28, тогда же делаем фильтр._
- [ ] **#33** — (RS5, низкий) Площади считаются планарно в градусах (JTS `area` на EPSG:4326) — искажение для крупных/высокоширотных заявок. Перейти на геодезию (`ST_Area(geography)` или равновеликая проекция), когда вырастут требования к точности. _Отложено по решению аудита: покрытие считается отношением площадей (планар ≈ геодезия для обычных AOI), а переключение `area()` затрагивает matching и точечные ассерты тестов — вводим при росте требований к точности._
--- ---
## Выполнено ## Выполнено
- [x] **#34** — (RS6) Проброс требуемого процента покрытия из API + разведение терминов. _Готово: в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent` (0 < x ≤ 100, по умолчанию 100), оно пробрасывается в сущность и используется как порог COMPLETED; захардкоженная константа убрана. В KDoc/коде разведены «прогресс покрытия» (`coverageRequiredPercent`) и «важность заявки» (`importance`, карта приоритетов ячеек). Тесты: `RequestServiceTest`, `RequestApiDtoContractTest`._
- [x] **#31** — (RS3) DLQ и error-handler для Kafka-листенера маршрутов. _Готово: `KafkaConsumerConfig` — `DefaultErrorHandler` + `DeadLetterPublishingRecoverer` в топик `pcp.route.georeference-dlq.v1`, экспоненциальный bounded retry, контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`) → DLQ без ретраев, диагностические заголовки. Настройки `app.kafka.consumer.route.*`, активируется при заданном `spring.kafka.bootstrap-servers`. Тест `KafkaConsumerConfigTest`._
- [x] **#35** — (RS7) Уведомление **geoportal** о финале заявки. _Готово: единый топик `pcp.request.status.v1` (заменил orphan `pcp.request.completed.v1`) с терминальным статусом в payload (`COMPLETED`/`EXPIRED`). Эмиссия из двух мест через тот же transactional outbox: `RouteMatchingService` (по покрытию) и `RequestExpiryService` (по сроку, #29). Сервис `RequestCompletedOutboxService``RequestStatusOutboxService`, eventType `REQUEST_TERMINAL_STATUS`. Outbox получил backoff: колонки `attempts`/`next_attempt_at`, удвоение `initial→max`, предел `max-attempts` (терминальный FAILED). Конфиг `pcp.outbox.*`. Тесты: `OutboxPublisherServiceTest`, `OutboxEventRepositoryJpaTest`, `RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RouteMatchingServiceTest`. Док: интеракции обновлены. Известный мелкий зазор: заявка, созданная уже просроченной, события не шлёт (вырожденный кейс)._
- [x] **#29** — (RS1) Планировщик истечения заявок. _Готово: `RequestExpiryService` (`@Scheduled`, `FOR UPDATE SKIP LOCKED`, батчи с верхним лимитом) переводит просроченные `ACCEPTED`/`ACTIVE` в `EXPIRED`; несколько инстансов безопасны. `refreshStatus` теперь вызывается и при создании заявки — статус приёма отражает окно (`ACCEPTED`/`ACTIVE`/`EXPIRED`), а не всегда `ACCEPTED`. Конфиг `pcp.request.expiry.*` в config-repo. Тесты: `RequestExpiryServiceTest` (юнит) + `RequestRepositoryJpaTest` (H2). Разблокирует EXPIRED-эмиссию в #35._
- [x] **#32** — (RS4) Удалить мёртвый конфиг сервиса заданий. _Готово: из `config-repo/pcp-request-service.yaml` убраны `app.request.cleanup-cron`/`cleanup-retention-days` и неиспользуемый топик `app.kafka.topics.request` (`pcp.request.in.v1`); кода под ними не было, `@Value`-привязок нет._
- [x] **#26** — Проверить сервис заданий (`pcp-request-service`) по отдельности. _Готово: аудит в [аудит-сервис-заданий.md](аудит-сервис-заданий.md) — карта взаимодействий, учёт покрытия, жизненный цикл, мультиинстанс; находки RS1–RS7 (RS1/RS2 — высокий приоритет)._
- [x] **#9** — Расширить вправо левую панель информации на вкладке «Редактор» (цифры у границы смотрелись плохо). _Готово: commit 69dfd80, рейл 15.5rem → 17rem._ - [x] **#9** — Расширить вправо левую панель информации на вкладке «Редактор» (цифры у границы смотрелись плохо). _Готово: commit 69dfd80, рейл 15.5rem → 17rem._
- [x] **#10** — Проставлять статусы слотов только после того, как план заложен на КА. _Готово: commit 69dfd80, слоты занимаются по `LAID_IN`, а не по `ACCEPT`._ - [x] **#10** — Проставлять статусы слотов только после того, как план заложен на КА. _Готово: commit 69dfd80, слоты занимаются по `LAID_IN`, а не по `ACCEPT`._
- [x] **#11** — Запретить нажимать «Подтвердить закладку», пока начало закладки в будущем. _Готово: commit 69dfd80._ - [x] **#11** — Запретить нажимать «Подтвердить закладку», пока начало закладки в будущем. _Готово: commit 69dfd80._
@@ -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"
}
}
@@ -4,7 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "pcp.outbox") @ConfigurationProperties(prefix = "pcp.outbox")
data class OutboxProperties( 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 publishBatchSize: Int = 50,
val publishFixedDelayMs: Long = 5_000, val publishFixedDelayMs: Long = 5_000,
/** Сколько раз пытаемся опубликовать событие до перевода в терминальный FAILED. */
val maxAttempts: Int = 10,
/** Backoff после первой неудачной публикации. */
val initialBackoffMs: Long = 1_000,
/** Верхняя граница backoff между попытками. */
val maxBackoffMs: Long = 60_000,
) )
@@ -22,4 +22,15 @@ class OutboxTransactionConfig {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW 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
}
}
} }
@@ -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,
)
@@ -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,
)
@@ -60,6 +60,15 @@ data class CreateRequestDto(
/** Признак высокоприоритетной передачи. */ /** Признак высокоприоритетной передачи. */
val highPriorityTransmit: Boolean = false, 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 @field:Valid
val optics: OpticsParamsDto? = null, val optics: OpticsParamsDto? = null,
@@ -48,6 +48,14 @@ class OutboxEventEntity(
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime, 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") @Column(name = "published_at")
var publishedAt: LocalDateTime? = null, var publishedAt: LocalDateTime? = null,
@@ -16,8 +16,9 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List<OutboxEventEntity> fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List<OutboxEventEntity>
/** /**
* Locks publishable REQUEST_COMPLETED events so parallel service instances skip rows already * Locks publishable events so parallel service instances skip rows already being published by
* being published by another transaction. * another transaction. Учитывает backoff (`next_attempt_at`) и предел попыток (`attempts`):
* терминальные FAILED (`attempts >= maxAttempts`) и ещё не «дозревшие» строки исключаются.
*/ */
@Query( @Query(
value = """ value = """
@@ -25,6 +26,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
FROM outbox_events FROM outbox_events
WHERE event_type = :eventType WHERE event_type = :eventType
AND status IN ('NEW', 'FAILED') AND status IN ('NEW', 'FAILED')
AND attempts < :maxAttempts
AND next_attempt_at <= :now
ORDER BY ORDER BY
CASE WHEN status = 'NEW' THEN 0 ELSE 1 END, CASE WHEN status = 'NEW' THEN 0 ELSE 1 END,
created_at ASC created_at ASC
@@ -33,8 +36,10 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
""", """,
nativeQuery = true, nativeQuery = true,
) )
fun findRequestCompletedPublishableForUpdateSkipLocked( fun findPublishableForUpdateSkipLocked(
@Param("eventType") eventType: String, @Param("eventType") eventType: String,
@Param("now") now: LocalDateTime,
@Param("maxAttempts") maxAttempts: Int,
@Param("batchSize") batchSize: Int, @Param("batchSize") batchSize: Int,
): List<OutboxEventEntity> ): List<OutboxEventEntity>
@@ -52,6 +57,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
aggregate_id, aggregate_id,
payload, payload,
status, status,
attempts,
next_attempt_at,
created_at, created_at,
published_at, published_at,
error_message error_message
@@ -63,6 +70,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
:aggregateId, :aggregateId,
CAST(:payload AS jsonb), CAST(:payload AS jsonb),
:status, :status,
0,
:createdAt,
:createdAt, :createdAt,
NULL, NULL,
NULL NULL
@@ -106,4 +106,27 @@ interface RequestRepository : JpaRepository<RequestEntity, UUID> {
): List<RequestEntity> ): List<RequestEntity>
fun findByStatusIn(statuses: Collection<RequestStatus>): 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>
} }
@@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations import org.springframework.transaction.support.TransactionOperations
import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@@ -41,60 +42,91 @@ class OutboxPublisherService(
return try { return try {
transactionOperations.execute( transactionOperations.execute(
TransactionCallback { TransactionCallback {
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked( val now = LocalDateTime.now()
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE,
now = now,
maxAttempts = outboxProperties.maxAttempts,
batchSize = batchSize, batchSize = batchSize,
).distinctBy { it.id } ).distinctBy { it.id }
events.forEach { event -> events.forEach { event ->
publishAndUpdateStatus(event) publishAndUpdateStatus(event, now)
} }
events.size events.size
}, },
) ?: 0 ) ?: 0
} catch (exception: Exception) { } 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 0
} }
} }
private fun publishAndUpdateStatus(event: OutboxEventEntity) { private fun publishAndUpdateStatus(event: OutboxEventEntity, now: LocalDateTime) {
try { try {
kafkaTemplate kafkaTemplate
.send( .send(
outboxProperties.requestCompletedTopic, outboxProperties.requestStatusTopic,
event.aggregateId.toString(), event.aggregateId.toString(),
event.payload.toString(), event.payload.toString(),
) )
.get() .get()
event.status = OutboxEventStatus.PUBLISHED event.status = OutboxEventStatus.PUBLISHED
event.publishedAt = LocalDateTime.now() event.publishedAt = now
event.errorMessage = null event.errorMessage = null
outboxEventRepository.save(event) outboxEventRepository.save(event)
log.info( log.info(
"Published REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}", "Published REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, topic={}",
event.id, event.id,
event.aggregateId, event.aggregateId,
outboxProperties.requestCompletedTopic, outboxProperties.requestStatusTopic,
) )
} catch (exception: Exception) { } catch (exception: Exception) {
val publishException = exception.unwrapExecutionException() val publishException = exception.unwrapExecutionException()
event.attempts += 1
event.status = OutboxEventStatus.FAILED event.status = OutboxEventStatus.FAILED
event.errorMessage = publishException.shortMessage() 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) outboxEventRepository.save(event)
val exhausted = event.attempts >= outboxProperties.maxAttempts
log.warn( 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.id,
event.aggregateId, event.aggregateId,
outboxProperties.requestCompletedTopic, outboxProperties.requestStatusTopic,
event.attempts,
outboxProperties.maxAttempts,
exhausted,
event.nextAttemptAt,
publishException, 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 { private fun Exception.unwrapExecutionException(): Throwable {
return if (this is ExecutionException && cause != null) { return if (this is ExecutionException && cause != null) {
cause!! cause!!
@@ -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)
}
}
}
@@ -51,6 +51,7 @@ class RequestService(
private val requestCellRepository: RequestCellRepository, private val requestCellRepository: RequestCellRepository,
private val geometryService: GeometryService, private val geometryService: GeometryService,
private val requestGridProjectionService: RequestGridProjectionService, private val requestGridProjectionService: RequestGridProjectionService,
private val requestLifecycleService: RequestLifecycleService,
private val entityManager: EntityManager, private val entityManager: EntityManager,
) { ) {
@Transactional @Transactional
@@ -78,7 +79,9 @@ class RequestService(
remainingGeometry = normalizedGeometryWkt, remainingGeometry = normalizedGeometryWkt,
geometryArea = geometryArea, geometryArea = geometryArea,
remainingArea = geometryArea, remainingArea = geometryArea,
coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT, // Порог завершения по покрытию приходит из API (по умолчанию 100%); это прогресс съёмки,
// в отличие от importance — важности заявки для карты приоритетов ячеек.
coverageRequiredPercent = request.coverageRequiredPercent,
importance = importance, importance = importance,
beginDateTime = request.beginDateTime, beginDateTime = request.beginDateTime,
endDateTime = request.endDateTime, endDateTime = request.endDateTime,
@@ -90,6 +93,10 @@ class RequestService(
updatedAt = now, updatedAt = now,
) )
// Окно заявки может уже идти или закончиться к моменту приёма — сразу выставляем
// корректный статус (ACCEPTED/ACTIVE/EXPIRED), а не всегда ACCEPTED.
requestLifecycleService.refreshStatus(requestEntity, now)
persistNewRequest(requestEntity) persistNewRequest(requestEntity)
replaceSurveyParams(requestEntity.id, request.optics, request.rsa) replaceSurveyParams(requestEntity.id, request.optics, request.rsa)
requestGridProjectionService.rebuildProjection(requestEntity) requestGridProjectionService.rebuildProjection(requestEntity)
@@ -97,7 +104,7 @@ class RequestService(
return CreateRequestResponseDto( return CreateRequestResponseDto(
id = requestEntity.id, id = requestEntity.id,
name = requestEntity.name, name = requestEntity.name,
status = RequestStatusDto.ACCEPTED, status = requestEntity.status.toDto(),
surveyType = requestEntity.surveyType.toDto(), surveyType = requestEntity.surveyType.toDto(),
importance = requestEntity.importance, importance = requestEntity.importance,
message = "Заявка успешно создана", message = "Заявка успешно создана",
@@ -369,7 +376,6 @@ class RequestService(
} }
private companion object { private companion object {
const val DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0
const val POSTGRES_UNIQUE_VIOLATION = "23505" const val POSTGRES_UNIQUE_VIOLATION = "23505"
const val REQUESTS_PRIMARY_KEY = "requests_pkey" const val REQUESTS_PRIMARY_KEY = "requests_pkey"
const val DEFAULT_SORT_FIELD = "createdAt" const val DEFAULT_SORT_FIELD = "createdAt"
@@ -12,8 +12,15 @@ import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
/**
* Складывает в transactional outbox единое событие о **терминальном** статусе заявки
* (`COMPLETED` или `EXPIRED`) для geoportal. Идемпотентно по `(event_type, aggregate_id)`:
* на заявку приходится не более одного терминального события.
*
* Публикуется [OutboxPublisherService] в топик `pcp.request.status.v1` со статусом в payload.
*/
@Service @Service
class RequestCompletedOutboxService( class RequestStatusOutboxService(
private val outboxEventRepository: OutboxEventRepository, private val outboxEventRepository: OutboxEventRepository,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
@@ -23,12 +30,17 @@ class RequestCompletedOutboxService(
private val log = LoggerFactory.getLogger(this::class.java) private val log = LoggerFactory.getLogger(this::class.java)
@Transactional(propagation = Propagation.MANDATORY) @Transactional(propagation = Propagation.MANDATORY)
fun createRequestCompletedIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) { fun createTerminalIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) {
val completedAt = request.completedAt ?: return val status = request.status
val payload = RequestCompletedPayload( require(status == RequestStatus.COMPLETED || status == RequestStatus.EXPIRED) {
"Терминальное outbox-событие требует COMPLETED/EXPIRED, получено: $status"
}
val payload = RequestStatusPayload(
requestId = request.id, requestId = request.id,
status = RequestStatus.COMPLETED.name, status = status.name,
completedAt = completedAt, // Для COMPLETED — момент достижения покрытия, для EXPIRED — момент фиксации истечения.
occurredAt = request.completedAt ?: eventCreatedAt,
coveragePercent = geometryService.coveredPercent(request.geometryArea, request.remainingArea), coveragePercent = geometryService.coveredPercent(request.geometryArea, request.remainingArea),
matchedAt = request.lastMatchedAt, matchedAt = request.lastMatchedAt,
eventCreatedAt = eventCreatedAt, eventCreatedAt = eventCreatedAt,
@@ -36,7 +48,7 @@ class RequestCompletedOutboxService(
val inserted = outboxEventRepository.insertIfAbsent( val inserted = outboxEventRepository.insertIfAbsent(
id = UUID.randomUUID(), id = UUID.randomUUID(),
eventType = REQUEST_COMPLETED_EVENT_TYPE, eventType = REQUEST_STATUS_EVENT_TYPE,
aggregateType = REQUEST_AGGREGATE_TYPE, aggregateType = REQUEST_AGGREGATE_TYPE,
aggregateId = request.id, aggregateId = request.id,
payload = objectMapper.writeValueAsString(payload), payload = objectMapper.writeValueAsString(payload),
@@ -46,23 +58,23 @@ class RequestCompletedOutboxService(
if (inserted == 0) { if (inserted == 0) {
log.debug( log.debug(
"REQUEST_COMPLETED outbox event already exists for requestId={}", "Терминальное outbox-событие уже существует для requestId={}",
request.id, request.id,
) )
} }
} }
private data class RequestCompletedPayload( private data class RequestStatusPayload(
val requestId: UUID, val requestId: UUID,
val status: String, val status: String,
val completedAt: LocalDateTime, val occurredAt: LocalDateTime,
val coveragePercent: Double, val coveragePercent: Double,
val matchedAt: LocalDateTime?, val matchedAt: LocalDateTime?,
val eventCreatedAt: LocalDateTime, val eventCreatedAt: LocalDateTime,
) )
companion object { 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" const val REQUEST_AGGREGATE_TYPE = "REQUEST"
} }
} }
@@ -31,7 +31,7 @@ class RouteMatchingService(
private val requestLifecycleService: RequestLifecycleService, private val requestLifecycleService: RequestLifecycleService,
private val requestCompletedOutboxService: RequestCompletedOutboxService, private val requestStatusOutboxService: RequestStatusOutboxService,
@param:Qualifier("routeMatchingTransactionOperations") @param:Qualifier("routeMatchingTransactionOperations")
private val routeMatchingTransactionOperations: TransactionOperations, private val routeMatchingTransactionOperations: TransactionOperations,
@@ -168,7 +168,7 @@ class RouteMatchingService(
val statusRefresh = requestLifecycleService.refreshStatus(request, matchedAt) val statusRefresh = requestLifecycleService.refreshStatus(request, matchedAt)
requestRepository.save(request) requestRepository.save(request)
if (statusRefresh.completedNow) { if (statusRefresh.completedNow) {
requestCompletedOutboxService.createRequestCompletedIfAbsent(request, matchedAt) requestStatusOutboxService.createTerminalIfAbsent(request, matchedAt)
} }
return true return true
} }
@@ -14,6 +14,9 @@ spring:
pcp: pcp:
outbox: outbox:
request-completed-topic: pcp.request.completed.v1 request-status-topic: pcp.request.status.v1
publish-batch-size: 50 publish-batch-size: 50
publish-fixed-delay-ms: 5000 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, aggregate_id UUID NOT NULL,
payload JSONB NOT NULL, payload JSONB NOT NULL,
status VARCHAR(32) 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, created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP NULL, published_at TIMESTAMP NULL,
error_message TEXT 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_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_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_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_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_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); CREATE INDEX IF NOT EXISTS idx_request_cells_request_id ON request_cells(request_id);
@@ -49,8 +49,11 @@ class MigrationSchemaTest {
assertTrue(outboxEvents.contains("payload jsonb not null")) assertTrue(outboxEvents.contains("payload jsonb not null"))
assertTrue(outboxEvents.contains("status varchar(32) 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(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(status, created_at)"))
assertTrue(schema.contains("on outbox_events(event_type, status, next_attempt_at, created_at)"))
} }
@Test @Test
@@ -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>
}
}
@@ -25,6 +25,7 @@ import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
import org.nstart.dep265.requestservice.service.GeometryService import org.nstart.dep265.requestservice.service.GeometryService
import org.nstart.dep265.requestservice.service.RequestGridProjectionService import org.nstart.dep265.requestservice.service.RequestGridProjectionService
import org.nstart.dep265.requestservice.service.RequestLifecycleService
import org.nstart.dep265.requestservice.service.RequestService import org.nstart.dep265.requestservice.service.RequestService
import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
@@ -98,6 +99,7 @@ class RequestControllerOpenApiSkeletonTest {
requestCellRepository = requestCellRepository, requestCellRepository = requestCellRepository,
geometryService = GeometryService(), geometryService = GeometryService(),
requestGridProjectionService = mock(RequestGridProjectionService::class.java), requestGridProjectionService = mock(RequestGridProjectionService::class.java),
requestLifecycleService = RequestLifecycleService(GeometryService()),
entityManager = entityManager, entityManager = entityManager,
) )
) )
@@ -114,6 +116,7 @@ class RequestControllerOpenApiSkeletonTest {
@Test @Test
fun `post v1 requests returns create response`() { fun `post v1 requests returns create response`() {
// Окно в будущем → приём детерминированно даёт ACCEPTED (createRequest зовёт refreshStatus).
mockMvc.perform( mockMvc.perform(
post("/v1/requests") post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -125,8 +128,8 @@ class RequestControllerOpenApiSkeletonTest {
"name": "Тест1", "name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "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, "importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z", "beginDateTime": "2090-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z", "endDateTime": "2090-01-31T23:59:59Z",
"kpp": [1, 2, 3], "kpp": [1, 2, 3],
"highPriorityTransmit": false, "highPriorityTransmit": false,
"optics": { "optics": {
@@ -45,6 +45,31 @@ class RequestApiDtoContractTest {
assertEquals(10.0, request.importance) assertEquals(10.0, request.importance)
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime) assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime)
assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType) 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 @Test
@@ -82,19 +82,77 @@ class OutboxEventRepositoryJpaTest @Autowired constructor(
) )
outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType)) outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType))
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked( val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
eventType = "REQUEST_COMPLETED", eventType = "REQUEST_TERMINAL_STATUS",
now = LocalDateTime.parse("2026-06-06T00:00:00"),
maxAttempts = 10,
batchSize = 10, batchSize = 10,
) )
assertEquals(listOf(newEvent.id, oldFailedEvent.id, newerFailedEvent.id), events.map { it.id }) 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( private fun outboxEvent(
requestId: UUID, requestId: UUID,
eventType: String = "REQUEST_COMPLETED", eventType: String = "REQUEST_TERMINAL_STATUS",
status: OutboxEventStatus = OutboxEventStatus.NEW, status: OutboxEventStatus = OutboxEventStatus.NEW,
createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
attempts: Int = 0,
nextAttemptAt: LocalDateTime = createdAt,
): OutboxEventEntity { ): OutboxEventEntity {
return OutboxEventEntity( return OutboxEventEntity(
id = UUID.randomUUID(), id = UUID.randomUUID(),
@@ -104,6 +162,8 @@ class OutboxEventRepositoryJpaTest @Autowired constructor(
payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""), payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""),
status = status, status = status,
createdAt = createdAt, createdAt = createdAt,
attempts = attempts,
nextAttemptAt = nextAttemptAt,
) )
} }
} }
@@ -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,
)
}
}
@@ -6,6 +6,8 @@ import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyString import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doReturn import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doAnswer import org.mockito.Mockito.doAnswer
@@ -30,13 +32,16 @@ import java.util.concurrent.CompletableFuture
class OutboxPublisherServiceTest { class OutboxPublisherServiceTest {
private val objectMapper = jacksonObjectMapper() private val objectMapper = jacksonObjectMapper()
private val properties = OutboxProperties( private val properties = OutboxProperties(
requestCompletedTopic = "pcp.request.completed.v1", requestStatusTopic = "pcp.request.status.v1",
publishBatchSize = 50, publishBatchSize = 50,
publishFixedDelayMs = 5_000, publishFixedDelayMs = 5_000,
maxAttempts = 10,
initialBackoffMs = 1_000,
maxBackoffMs = 60_000,
) )
@Test @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 requestId = UUID.fromString("6cc8ed4b-0db1-4cfb-bef0-d56eed2edce4")
val event = outboxEvent( val event = outboxEvent(
requestId = requestId, requestId = requestId,
@@ -50,7 +55,7 @@ class OutboxPublisherServiceTest {
assertEquals(1, processedCount) assertEquals(1, processedCount)
verify(kafkaTemplate).send( verify(kafkaTemplate).send(
properties.requestCompletedTopic, properties.requestStatusTopic,
requestId.toString(), requestId.toString(),
event.payload.toString(), event.payload.toString(),
) )
@@ -61,11 +66,12 @@ class OutboxPublisherServiceTest {
} }
@Test @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 requestId = UUID.fromString("9986e096-886a-429e-af7f-3a6178c29b4f")
val event = outboxEvent( val event = outboxEvent(
requestId = requestId, requestId = requestId,
status = OutboxEventStatus.FAILED, status = OutboxEventStatus.FAILED,
attempts = 1,
errorMessage = "previous failure", errorMessage = "previous failure",
) )
val outboxEventRepository = repositoryReturning(listOf(event)) val outboxEventRepository = repositoryReturning(listOf(event))
@@ -75,7 +81,7 @@ class OutboxPublisherServiceTest {
service.publishPending() service.publishPending()
verify(kafkaTemplate).send( verify(kafkaTemplate).send(
properties.requestCompletedTopic, properties.requestStatusTopic,
requestId.toString(), requestId.toString(),
event.payload.toString(), event.payload.toString(),
) )
@@ -95,14 +101,14 @@ class OutboxPublisherServiceTest {
service.publishPending() service.publishPending()
verify(kafkaTemplate).send( verify(kafkaTemplate).send(
properties.requestCompletedTopic, properties.requestStatusTopic,
requestId.toString(), requestId.toString(),
objectMapper.readTree(savedPayload).toString(), objectMapper.readTree(savedPayload).toString(),
) )
} }
@Test @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 requestId = UUID.fromString("116eb4c7-cd6d-4837-8525-21c1d16ecbbb")
val event = outboxEvent(requestId = requestId) val event = outboxEvent(requestId = requestId)
val outboxEventRepository = repositoryReturning(listOf(event)) val outboxEventRepository = repositoryReturning(listOf(event))
@@ -113,17 +119,42 @@ class OutboxPublisherServiceTest {
assertEquals(1, processedCount) assertEquals(1, processedCount)
assertEquals(OutboxEventStatus.FAILED, event.status) assertEquals(OutboxEventStatus.FAILED, event.status)
assertEquals(1, event.attempts)
assertTrue(event.errorMessage!!.contains("broker unavailable")) assertTrue(event.errorMessage!!.contains("broker unavailable"))
assertNull(event.publishedAt) assertNull(event.publishedAt)
// backoff: следующая попытка отложена в будущее относительно создания события.
assertTrue(event.nextAttemptAt.isAfter(event.createdAt))
verify(outboxEventRepository).save(event) 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 @Test
fun `failed event is not retried repeatedly in same publish pending call`() { fun `failed event is not retried repeatedly in same publish pending call`() {
val requestId = UUID.fromString("f31992a5-efae-4d9a-8262-78381da835f7") val requestId = UUID.fromString("f31992a5-efae-4d9a-8262-78381da835f7")
val event = outboxEvent( val event = outboxEvent(
requestId = requestId, requestId = requestId,
status = OutboxEventStatus.FAILED, status = OutboxEventStatus.FAILED,
attempts = 1,
errorMessage = "previous failure", errorMessage = "previous failure",
) )
val outboxEventRepository = repositoryReturning(listOf(event)) val outboxEventRepository = repositoryReturning(listOf(event))
@@ -134,15 +165,18 @@ class OutboxPublisherServiceTest {
assertEquals(1, processedCount) assertEquals(1, processedCount)
verify(kafkaTemplate, times(1)).send( verify(kafkaTemplate, times(1)).send(
properties.requestCompletedTopic, properties.requestStatusTopic,
requestId.toString(), requestId.toString(),
event.payload.toString(), event.payload.toString(),
) )
verify(outboxEventRepository, times(1)).findRequestCompletedPublishableForUpdateSkipLocked( verify(outboxEventRepository, times(1)).findPublishableForUpdateSkipLocked(
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, anyString(),
properties.publishBatchSize, anyLocalDateTime(),
anyInt(),
anyInt(),
) )
assertEquals(OutboxEventStatus.FAILED, event.status) assertEquals(OutboxEventStatus.FAILED, event.status)
assertEquals(2, event.attempts)
assertTrue(event.errorMessage!!.contains("still broken")) assertTrue(event.errorMessage!!.contains("still broken"))
} }
@@ -153,6 +187,7 @@ class OutboxPublisherServiceTest {
val failedEvent = outboxEvent( val failedEvent = outboxEvent(
requestId = failedRequestId, requestId = failedRequestId,
status = OutboxEventStatus.FAILED, status = OutboxEventStatus.FAILED,
attempts = 1,
errorMessage = "previous failure", errorMessage = "previous failure",
) )
val newEvent = outboxEvent(requestId = newRequestId) val newEvent = outboxEvent(requestId = newRequestId)
@@ -164,12 +199,12 @@ class OutboxPublisherServiceTest {
assertEquals(2, processedCount) assertEquals(2, processedCount)
verify(kafkaTemplate).send( verify(kafkaTemplate).send(
properties.requestCompletedTopic, properties.requestStatusTopic,
failedRequestId.toString(), failedRequestId.toString(),
failedEvent.payload.toString(), failedEvent.payload.toString(),
) )
verify(kafkaTemplate).send( verify(kafkaTemplate).send(
properties.requestCompletedTopic, properties.requestStatusTopic,
newRequestId.toString(), newRequestId.toString(),
newEvent.payload.toString(), newEvent.payload.toString(),
) )
@@ -206,9 +241,11 @@ class OutboxPublisherServiceTest {
val outboxEventRepository = mock(OutboxEventRepository::class.java) val outboxEventRepository = mock(OutboxEventRepository::class.java)
doReturn(events) doReturn(events)
.`when`(outboxEventRepository) .`when`(outboxEventRepository)
.findRequestCompletedPublishableForUpdateSkipLocked( .findPublishableForUpdateSkipLocked(
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, anyString(),
properties.publishBatchSize, anyLocalDateTime(),
anyInt(),
anyInt(),
) )
return outboxEventRepository return outboxEventRepository
} }
@@ -256,20 +293,31 @@ class OutboxPublisherServiceTest {
requestId: UUID, requestId: UUID,
payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""", payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""",
status: OutboxEventStatus = OutboxEventStatus.NEW, status: OutboxEventStatus = OutboxEventStatus.NEW,
attempts: Int = 0,
errorMessage: String? = null, errorMessage: String? = null,
): OutboxEventEntity { ): OutboxEventEntity {
val createdAt = LocalDateTime.parse("2026-01-01T10:00:00")
return OutboxEventEntity( return OutboxEventEntity(
id = UUID.randomUUID(), id = UUID.randomUUID(),
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE,
aggregateType = RequestCompletedOutboxService.REQUEST_AGGREGATE_TYPE, aggregateType = RequestStatusOutboxService.REQUEST_AGGREGATE_TYPE,
aggregateId = requestId, aggregateId = requestId,
payload = objectMapper.readTree(payload), payload = objectMapper.readTree(payload),
status = status, status = status,
createdAt = LocalDateTime.parse("2026-01-01T10:00:00"), createdAt = createdAt,
attempts = attempts,
nextAttemptAt = createdAt,
errorMessage = errorMessage, 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 { private class ImmediateTransactionOperations : TransactionOperations {
override fun <T : Any?> execute(action: TransactionCallback<T>): T { override fun <T : Any?> execute(action: TransactionCallback<T>): T {
return action.doInTransaction(SimpleTransactionStatus()) return action.doInTransaction(SimpleTransactionStatus())
@@ -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"])
}
}
@@ -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)
}
}
}
@@ -50,6 +50,7 @@ class RequestServiceTest {
requestCellRepository = requestCellRepository, requestCellRepository = requestCellRepository,
geometryService = GeometryService(), geometryService = GeometryService(),
requestGridProjectionService = requestGridProjectionService, requestGridProjectionService = requestGridProjectionService,
requestLifecycleService = RequestLifecycleService(GeometryService()),
entityManager = entityManager, entityManager = entityManager,
) )
@@ -95,6 +96,22 @@ class RequestServiceTest {
verify(requestGridProjectionService).rebuildProjection(persistedRequests.single()) 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 @Test
fun `get request by id returns name and importance`() { fun `get request by id returns name and importance`() {
val request = existingRequest() val request = existingRequest()
@@ -279,7 +296,7 @@ class RequestServiceTest {
verify(requestCellRepository).deleteByRequestId(REQUEST_ID) verify(requestCellRepository).deleteByRequestId(REQUEST_ID)
} }
private fun createRequestDto(): CreateRequestDto { private fun createRequestDto(coverageRequiredPercent: Double = 100.0): CreateRequestDto {
return CreateRequestDto( return CreateRequestDto(
id = REQUEST_ID, id = REQUEST_ID,
name = REQUEST_NAME, name = REQUEST_NAME,
@@ -287,6 +304,7 @@ class RequestServiceTest {
importance = REQUEST_IMPORTANCE, importance = REQUEST_IMPORTANCE,
beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0), beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59), endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
coverageRequiredPercent = coverageRequiredPercent,
optics = OpticsParamsDto( optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC, resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0, resolution = 1.0,
@@ -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
}
}
@@ -288,7 +288,7 @@ class RouteMatchingServiceTest {
invocation -> invocation.method.name == "insertIfAbsent" invocation -> invocation.method.name == "insertIfAbsent"
} }
assertEquals(1, insertInvocations.size) 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(request.id, insertInvocations.single().arguments[3])
assertEquals(RequestStatus.COMPLETED, request.status) assertEquals(RequestStatus.COMPLETED, request.status)
} }
@@ -305,16 +305,16 @@ class RouteMatchingServiceTest {
requestValidationService = RequestValidationService(geometryService), requestValidationService = RequestValidationService(geometryService),
geometryService = geometryService, geometryService = geometryService,
requestLifecycleService = RequestLifecycleService(geometryService), requestLifecycleService = RequestLifecycleService(geometryService),
requestCompletedOutboxService = requestCompletedOutboxService(outboxEventRepository, geometryService), requestStatusOutboxService = requestStatusOutboxService(outboxEventRepository, geometryService),
routeMatchingTransactionOperations = ImmediateTransactionOperations(), routeMatchingTransactionOperations = ImmediateTransactionOperations(),
) )
} }
private fun requestCompletedOutboxService( private fun requestStatusOutboxService(
outboxEventRepository: OutboxEventRepository, outboxEventRepository: OutboxEventRepository,
geometryService: GeometryService, geometryService: GeometryService,
): RequestCompletedOutboxService { ): RequestStatusOutboxService {
return RequestCompletedOutboxService( return RequestStatusOutboxService(
outboxEventRepository = outboxEventRepository, outboxEventRepository = outboxEventRepository,
objectMapper = jacksonObjectMapper() objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule()) .registerModule(JavaTimeModule())