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:
@@ -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
|
||||||
|
|||||||
@@ -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` в рантайме.
|
||||||
|
|||||||
@@ -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 и тесты; покрытие — отношение площадей).
|
||||||
@@ -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._
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package org.nstart.dep265.requestservice.config
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException
|
||||||
|
import jakarta.validation.ConstraintViolationException
|
||||||
|
import org.apache.kafka.clients.consumer.ConsumerConfig
|
||||||
|
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||||
|
import org.apache.kafka.common.TopicPartition
|
||||||
|
import org.apache.kafka.common.header.Headers
|
||||||
|
import org.apache.kafka.common.header.internals.RecordHeader
|
||||||
|
import org.apache.kafka.common.header.internals.RecordHeaders
|
||||||
|
import org.apache.kafka.common.serialization.StringDeserializer
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
|
||||||
|
import org.springframework.kafka.core.ConsumerFactory
|
||||||
|
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate
|
||||||
|
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
|
||||||
|
import org.springframework.kafka.listener.DefaultErrorHandler
|
||||||
|
import org.springframework.util.backoff.ExponentialBackOff
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка ошибок Kafka-консьюмера маршрутов: bounded retry + DLQ.
|
||||||
|
*
|
||||||
|
* Без этого «ядовитое» сообщение бесконечно ретраится и блокирует партицию. Невалидные по контракту
|
||||||
|
* (ошибка десериализации/валидации) уходят в DLQ сразу, прочие — после ограниченного числа попыток.
|
||||||
|
*
|
||||||
|
* Активируется только при заданном `spring.kafka.bootstrap-servers`, чтобы не требовать Kafka в тестах
|
||||||
|
* без брокера.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
|
||||||
|
class KafkaConsumerConfig(
|
||||||
|
@param:Value("\${spring.kafka.bootstrap-servers}")
|
||||||
|
private val bootstrapServers: String,
|
||||||
|
@param:Value("\${spring.kafka.consumer.group-id}")
|
||||||
|
private val groupId: String,
|
||||||
|
@param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}")
|
||||||
|
private val autoOffsetReset: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun routeConsumerFactory(): ConsumerFactory<String, String> =
|
||||||
|
DefaultKafkaConsumerFactory(
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||||
|
ConsumerConfig.GROUP_ID_CONFIG to groupId,
|
||||||
|
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset,
|
||||||
|
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false,
|
||||||
|
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||||
|
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun kafkaListenerContainerFactory(
|
||||||
|
routeKafkaErrorHandler: DefaultErrorHandler,
|
||||||
|
): ConcurrentKafkaListenerContainerFactory<String, String> =
|
||||||
|
ConcurrentKafkaListenerContainerFactory<String, String>().apply {
|
||||||
|
setConsumerFactory(routeConsumerFactory())
|
||||||
|
setCommonErrorHandler(routeKafkaErrorHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun routeDeadLetterPublishingRecoverer(
|
||||||
|
kafkaTemplate: KafkaTemplate<String, String>,
|
||||||
|
routeConsumerProperties: RouteConsumerProperties,
|
||||||
|
): DeadLetterPublishingRecoverer =
|
||||||
|
DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
|
||||||
|
TopicPartition(routeConsumerProperties.dlqTopic, record.partition())
|
||||||
|
}.apply {
|
||||||
|
addHeadersFunction { record, exception -> routeDlqHeaders(record, exception) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun routeKafkaErrorHandler(
|
||||||
|
routeDeadLetterPublishingRecoverer: DeadLetterPublishingRecoverer,
|
||||||
|
routeConsumerProperties: RouteConsumerProperties,
|
||||||
|
): DefaultErrorHandler =
|
||||||
|
DefaultErrorHandler(
|
||||||
|
routeDeadLetterPublishingRecoverer,
|
||||||
|
routeConsumerBackOff(routeConsumerProperties),
|
||||||
|
).apply {
|
||||||
|
// Контрактные ошибки (битый JSON, провал валидации) бесполезно ретраить — сразу в DLQ.
|
||||||
|
addNotRetryableExceptions(
|
||||||
|
JsonProcessingException::class.java,
|
||||||
|
ConstraintViolationException::class.java,
|
||||||
|
)
|
||||||
|
setCommitRecovered(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun routeConsumerBackOff(properties: RouteConsumerProperties): ExponentialBackOff =
|
||||||
|
ExponentialBackOff().apply {
|
||||||
|
initialInterval = properties.backoffMs
|
||||||
|
multiplier = RETRY_BACKOFF_MULTIPLIER
|
||||||
|
maxInterval = properties.maxBackoffMs
|
||||||
|
maxAttempts = (properties.maxAttempts - 1).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun routeDlqHeaders(
|
||||||
|
record: ConsumerRecord<*, *>,
|
||||||
|
exception: Exception,
|
||||||
|
): Headers =
|
||||||
|
RecordHeaders().apply {
|
||||||
|
addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME)
|
||||||
|
addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name)
|
||||||
|
addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty())
|
||||||
|
addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic())
|
||||||
|
addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString())
|
||||||
|
addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Headers.addStringHeader(name: String, value: String) {
|
||||||
|
add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SERVICE_NAME = "pcp-request-service"
|
||||||
|
const val RETRY_BACKOFF_MULTIPLIER = 2.0
|
||||||
|
|
||||||
|
const val HEADER_SERVICE_NAME = "pcp-service-name"
|
||||||
|
const val HEADER_EXCEPTION_CLASS = "pcp-exception-class"
|
||||||
|
const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message"
|
||||||
|
const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic"
|
||||||
|
const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition"
|
||||||
|
const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset"
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -4,7 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties
|
|||||||
|
|
||||||
@ConfigurationProperties(prefix = "pcp.outbox")
|
@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,
|
||||||
)
|
)
|
||||||
|
|||||||
+11
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package org.nstart.dep265.requestservice.config
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настройки планировщика истечения заявок: периодически переводит просроченные заявки
|
||||||
|
* (status ACCEPTED/ACTIVE, окно которых уже закончилось) в EXPIRED.
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "pcp.request.expiry")
|
||||||
|
data class RequestExpiryProperties(
|
||||||
|
/** Включён ли планировщик. */
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
/** Период запуска (мс), читается также в @Scheduled. */
|
||||||
|
val fixedDelayMs: Long = 60_000,
|
||||||
|
/** Сколько заявок захватывать за одну транзакцию (FOR UPDATE SKIP LOCKED). */
|
||||||
|
val batchSize: Int = 200,
|
||||||
|
/** Верхняя граница числа батчей за один прогон, чтобы не удерживать поток планировщика. */
|
||||||
|
val maxBatchesPerRun: Int = 50,
|
||||||
|
)
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package org.nstart.dep265.requestservice.config
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Min
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
|
import org.springframework.validation.annotation.Validated
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настройки обработки ошибок Kafka-консьюмера маршрутов: DLQ-топик и bounded retry.
|
||||||
|
* «Ядовитое» (невалидное по контракту) сообщение уходит в DLQ, а не блокирует партицию.
|
||||||
|
*/
|
||||||
|
@Validated
|
||||||
|
@ConfigurationProperties(prefix = "app.kafka.consumer.route")
|
||||||
|
data class RouteConsumerProperties(
|
||||||
|
/** Топик dead-letter для маршрутов, не прошедших обработку. */
|
||||||
|
val dlqTopic: String = "pcp.route.georeference-dlq.v1",
|
||||||
|
|
||||||
|
/** Максимум попыток обработки, включая первичную доставку. */
|
||||||
|
@field:Min(1)
|
||||||
|
val maxAttempts: Long = 5,
|
||||||
|
|
||||||
|
/** Начальная задержка retry, мс. */
|
||||||
|
@field:Min(0)
|
||||||
|
val backoffMs: Long = 1_000,
|
||||||
|
|
||||||
|
/** Верхняя граница задержки retry, мс. */
|
||||||
|
@field:Min(0)
|
||||||
|
val maxBackoffMs: Long = 30_000,
|
||||||
|
)
|
||||||
+9
@@ -60,6 +60,15 @@ data class CreateRequestDto(
|
|||||||
/** Признак высокоприоритетной передачи. */
|
/** Признак высокоприоритетной передачи. */
|
||||||
val highPriorityTransmit: Boolean = false,
|
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,
|
||||||
|
|||||||
+8
@@ -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,
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -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
|
||||||
|
|||||||
+23
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-11
@@ -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!!
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
package org.nstart.dep265.requestservice.service
|
||||||
|
|
||||||
|
import org.nstart.dep265.requestservice.config.RequestExpiryProperties
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||||
|
import org.nstart.dep265.requestservice.repository.RequestRepository
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.support.TransactionCallback
|
||||||
|
import org.springframework.transaction.support.TransactionOperations
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планировщик истечения заявок.
|
||||||
|
*
|
||||||
|
* [RequestLifecycleService.refreshStatus] вызывается только при матчинге маршрута, поэтому заявка,
|
||||||
|
* к которой не пришло ни одного маршрута, никогда не уходила в EXPIRED после окончания окна.
|
||||||
|
* Этот воркер периодически переводит такие заявки в EXPIRED.
|
||||||
|
*
|
||||||
|
* Несколько инстансов безопасны: батч строк захватывается через FOR UPDATE SKIP LOCKED, так что
|
||||||
|
* параллельные инстансы разбирают непересекающиеся заявки.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class RequestExpiryService(
|
||||||
|
private val requestRepository: RequestRepository,
|
||||||
|
|
||||||
|
private val requestLifecycleService: RequestLifecycleService,
|
||||||
|
|
||||||
|
private val requestStatusOutboxService: RequestStatusOutboxService,
|
||||||
|
|
||||||
|
private val requestExpiryProperties: RequestExpiryProperties,
|
||||||
|
|
||||||
|
@param:Qualifier("requestExpiryTransactionOperations")
|
||||||
|
private val transactionOperations: TransactionOperations,
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(this::class.java)
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "\${pcp.request.expiry.fixed-delay-ms:60000}")
|
||||||
|
fun expireOverdueScheduledBatch() {
|
||||||
|
if (!requestExpiryProperties.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expireOverdue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переводит просроченные заявки (ACCEPTED/ACTIVE с истёкшим окном) в EXPIRED.
|
||||||
|
*
|
||||||
|
* @return число заявок, перешедших в EXPIRED за прогон.
|
||||||
|
*/
|
||||||
|
fun expireOverdue(): Int {
|
||||||
|
val batchSize = requestExpiryProperties.batchSize.coerceAtLeast(0)
|
||||||
|
if (batchSize == 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val maxBatches = requestExpiryProperties.maxBatchesPerRun.coerceAtLeast(1)
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
|
||||||
|
var expiredTotal = 0
|
||||||
|
var batchIndex = 0
|
||||||
|
while (batchIndex < maxBatches) {
|
||||||
|
val result = try {
|
||||||
|
transactionOperations.execute(
|
||||||
|
TransactionCallback { expireBatch(now, batchSize) },
|
||||||
|
) ?: ExpireBatchResult.EMPTY
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
log.error("Failed to process request expiry batch", exception)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
expiredTotal += result.expiredCount
|
||||||
|
batchIndex += 1
|
||||||
|
if (result.claimedCount < batchSize) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredTotal > 0) {
|
||||||
|
log.info("Request expiry sweep finished: expired={}", expiredTotal)
|
||||||
|
}
|
||||||
|
return expiredTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expireBatch(now: LocalDateTime, batchSize: Int): ExpireBatchResult {
|
||||||
|
val candidates = requestRepository.findExpirableForUpdateSkipLocked(now, batchSize)
|
||||||
|
var expiredCount = 0
|
||||||
|
|
||||||
|
candidates.forEach { request ->
|
||||||
|
val refresh = requestLifecycleService.refreshStatus(request, now)
|
||||||
|
if (refresh.statusChanged) {
|
||||||
|
request.updatedAt = now
|
||||||
|
requestRepository.save(request)
|
||||||
|
if (request.status == RequestStatus.EXPIRED) {
|
||||||
|
expiredCount += 1
|
||||||
|
// Терминальный статус → уведомляем geoportal через тот же transactional outbox.
|
||||||
|
requestStatusOutboxService.createTerminalIfAbsent(request, now)
|
||||||
|
log.info(
|
||||||
|
"Request expired: requestId={}, endDateTime={}",
|
||||||
|
request.id,
|
||||||
|
request.endDateTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExpireBatchResult(claimedCount = candidates.size, expiredCount = expiredCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ExpireBatchResult(
|
||||||
|
val claimedCount: Int,
|
||||||
|
val expiredCount: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = ExpireBatchResult(claimedCount = 0, expiredCount = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-3
@@ -51,6 +51,7 @@ class RequestService(
|
|||||||
private val requestCellRepository: RequestCellRepository,
|
private val 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"
|
||||||
|
|||||||
+23
-11
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -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);
|
||||||
|
|||||||
+3
@@ -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
|
||||||
|
|||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package org.nstart.dep265.requestservice.config
|
||||||
|
|
||||||
|
import jakarta.validation.ConstraintViolation
|
||||||
|
import jakarta.validation.ConstraintViolationException
|
||||||
|
import org.apache.kafka.clients.consumer.Consumer
|
||||||
|
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||||
|
import org.apache.kafka.clients.producer.ProducerRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.ArgumentMatchers.any
|
||||||
|
import org.mockito.Mockito.mock
|
||||||
|
import org.mockito.Mockito.never
|
||||||
|
import org.mockito.Mockito.times
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
|
import org.mockito.Mockito.`when`
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations
|
||||||
|
import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate
|
||||||
|
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
|
||||||
|
import org.springframework.kafka.listener.DefaultErrorHandler
|
||||||
|
import org.springframework.kafka.listener.MessageListenerContainer
|
||||||
|
import org.springframework.kafka.support.SendResult
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
|
class KafkaConsumerConfigTest {
|
||||||
|
|
||||||
|
private val contextRunner = ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration::class.java))
|
||||||
|
.withUserConfiguration(TestConfiguration::class.java, KafkaConsumerConfig::class.java)
|
||||||
|
.withPropertyValues(
|
||||||
|
"spring.kafka.bootstrap-servers=localhost:9092",
|
||||||
|
"spring.kafka.consumer.group-id=pcp-request-service",
|
||||||
|
"spring.kafka.consumer.auto-offset-reset=earliest",
|
||||||
|
"app.kafka.consumer.route.dlq-topic=$DLQ_TOPIC",
|
||||||
|
"app.kafka.consumer.route.max-attempts=3",
|
||||||
|
"app.kafka.consumer.route.backoff-ms=0",
|
||||||
|
"app.kafka.consumer.route.max-backoff-ms=0",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `creates error handler and recoverer beans`() {
|
||||||
|
contextRunner.run { context ->
|
||||||
|
assertNotNull(context.getBean(DefaultErrorHandler::class.java))
|
||||||
|
assertNotNull(context.getBean(DeadLetterPublishingRecoverer::class.java))
|
||||||
|
|
||||||
|
val properties = context.getBean(RouteConsumerProperties::class.java)
|
||||||
|
assertEquals(DLQ_TOPIC, properties.dlqTopic)
|
||||||
|
assertEquals(3L, properties.maxAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recoverer publishes original record to configured dlq topic with diagnostics headers`() {
|
||||||
|
contextRunner.run { context ->
|
||||||
|
val kafkaTemplate = context.kafkaTemplate()
|
||||||
|
val recoverer = context.getBean(DeadLetterPublishingRecoverer::class.java)
|
||||||
|
val record = consumerRecord()
|
||||||
|
|
||||||
|
recoverer.accept(record, ConstraintViolationException("route payload invalid", emptySet<ConstraintViolation<*>>()))
|
||||||
|
|
||||||
|
val published = capturePublishedRecord(kafkaTemplate)
|
||||||
|
assertEquals(DLQ_TOPIC, published.topic())
|
||||||
|
assertEquals(record.partition(), published.partition())
|
||||||
|
assertEquals(record.key(), published.key())
|
||||||
|
assertEquals(record.value(), published.value())
|
||||||
|
assertEquals("pcp-request-service", published.headers().stringHeader("pcp-service-name"))
|
||||||
|
assertEquals(
|
||||||
|
ConstraintViolationException::class.java.name,
|
||||||
|
published.headers().stringHeader("pcp-exception-class"),
|
||||||
|
)
|
||||||
|
assertEquals("route payload invalid", published.headers().stringHeader("pcp-exception-message"))
|
||||||
|
assertEquals(ROUTE_TOPIC, published.headers().stringHeader("pcp-original-topic"))
|
||||||
|
assertEquals("3", published.headers().stringHeader("pcp-original-partition"))
|
||||||
|
assertEquals("42", published.headers().stringHeader("pcp-original-offset"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `contract violation is recovered to dlq without retry`() {
|
||||||
|
contextRunner.run { context ->
|
||||||
|
val kafkaTemplate = context.kafkaTemplate()
|
||||||
|
val errorHandler = context.getBean(DefaultErrorHandler::class.java)
|
||||||
|
|
||||||
|
val handled = errorHandler.handleOne(
|
||||||
|
ConstraintViolationException("route payload invalid", emptySet<ConstraintViolation<*>>()),
|
||||||
|
consumerRecord(),
|
||||||
|
mockConsumer(),
|
||||||
|
mock(MessageListenerContainer::class.java),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(handled)
|
||||||
|
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transient exception is retried with bounded policy before dlq recovery`() {
|
||||||
|
contextRunner.run { context ->
|
||||||
|
val kafkaTemplate = context.kafkaTemplate()
|
||||||
|
val errorHandler = context.getBean(DefaultErrorHandler::class.java)
|
||||||
|
val record = consumerRecord()
|
||||||
|
val consumer = mockConsumer()
|
||||||
|
val container = mock(MessageListenerContainer::class.java)
|
||||||
|
val exception = IllegalStateException("database is temporarily unavailable")
|
||||||
|
|
||||||
|
// maxAttempts=3 → две первые доставки ретраятся (seek), на третьей — DLQ.
|
||||||
|
assertFalse(errorHandler.handleOne(exception, record, consumer, container))
|
||||||
|
assertFalse(errorHandler.handleOne(exception, record, consumer, container))
|
||||||
|
verify(kafkaTemplate, never()).send(anyProducerRecord())
|
||||||
|
|
||||||
|
assertTrue(errorHandler.handleOne(exception, record, consumer, container))
|
||||||
|
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun consumerRecord(): ConsumerRecord<String, String> =
|
||||||
|
ConsumerRecord(ROUTE_TOPIC, 3, 42L, "route-key", """{"routeId":"route-1"}""")
|
||||||
|
|
||||||
|
private fun capturePublishedRecord(
|
||||||
|
kafkaTemplate: KafkaTemplate<String, String>,
|
||||||
|
): ProducerRecord<String, String> {
|
||||||
|
val captor = producerRecordCaptor()
|
||||||
|
verify(kafkaTemplate, times(1)).send(captor.capture())
|
||||||
|
return captor.value
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun producerRecordCaptor(): ArgumentCaptor<ProducerRecord<String, String>> =
|
||||||
|
ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor<ProducerRecord<String, String>>
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun mockConsumer(): Consumer<String, String> =
|
||||||
|
mock(Consumer::class.java) as Consumer<String, String>
|
||||||
|
|
||||||
|
private fun org.apache.kafka.common.header.Headers.stringHeader(name: String): String? =
|
||||||
|
lastHeader(name)?.value()?.toString(StandardCharsets.UTF_8)
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@EnableConfigurationProperties(RouteConsumerProperties::class)
|
||||||
|
private class TestConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun kafkaTemplate(): KafkaTemplate<String, String> {
|
||||||
|
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
|
||||||
|
val sendResult = mock(SendResult::class.java) as SendResult<String, String>
|
||||||
|
`when`(kafkaTemplate.send(anyProducerRecord()))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(sendResult))
|
||||||
|
return kafkaTemplate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ROUTE_TOPIC = "pcp.route.georeference.v1"
|
||||||
|
const val DLQ_TOPIC = "pcp.route.georeference-dlq.v1"
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun anyProducerRecord(): ProducerRecord<String, String> =
|
||||||
|
(any(ProducerRecord::class.java) as ProducerRecord<String, String>?)
|
||||||
|
?: ProducerRecord("stub-topic", "stub-key", "stub-payload")
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun org.springframework.context.ApplicationContext.kafkaTemplate(): KafkaTemplate<String, String> =
|
||||||
|
getBean(KafkaTemplate::class.java) as KafkaTemplate<String, String>
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -25,6 +25,7 @@ import org.nstart.dep265.requestservice.repository.RequestRepository
|
|||||||
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
|
import org.nstart.dep265.requestservice.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": {
|
||||||
|
|||||||
+25
@@ -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
|
||||||
|
|||||||
+63
-3
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package org.nstart.dep265.requestservice.repository
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestEntity
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestSurveyType
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
classes = [PcpRequestServiceApplication::class],
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
||||||
|
properties = [
|
||||||
|
"spring.config.import=",
|
||||||
|
"spring.cloud.config.enabled=false",
|
||||||
|
"spring.flyway.enabled=false",
|
||||||
|
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:request-expiry;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
|
||||||
|
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||||
|
"spring.datasource.username=sa",
|
||||||
|
"spring.datasource.password=",
|
||||||
|
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
|
||||||
|
"spring.kafka.listener.auto-startup=false",
|
||||||
|
"spring.kafka.consumer.group-id=request-service-test",
|
||||||
|
"app.kafka.topics.route=request-service-test-route",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class RequestRepositoryJpaTest @Autowired constructor(
|
||||||
|
private val requestRepository: RequestRepository,
|
||||||
|
) {
|
||||||
|
private val now: LocalDateTime = LocalDateTime.parse("2026-06-06T12:00:00")
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
requestRepository.deleteAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional
|
||||||
|
fun `expirable query returns only overdue accepted and active requests`() {
|
||||||
|
val overdueAccepted = request(status = RequestStatus.ACCEPTED, end = now.minusHours(1))
|
||||||
|
val overdueActive = request(status = RequestStatus.ACTIVE, end = now.minusHours(2))
|
||||||
|
val activeInWindow = request(status = RequestStatus.ACTIVE, end = now.plusHours(1))
|
||||||
|
val alreadyCompleted = request(status = RequestStatus.COMPLETED, end = now.minusHours(1))
|
||||||
|
val alreadyExpired = request(status = RequestStatus.EXPIRED, end = now.minusHours(1))
|
||||||
|
val deletedOverdue = request(
|
||||||
|
status = RequestStatus.DELETED,
|
||||||
|
end = now.minusHours(1),
|
||||||
|
deletedAt = now.minusMinutes(30),
|
||||||
|
)
|
||||||
|
requestRepository.saveAllAndFlush(
|
||||||
|
listOf(overdueAccepted, overdueActive, activeInWindow, alreadyCompleted, alreadyExpired, deletedOverdue),
|
||||||
|
)
|
||||||
|
|
||||||
|
val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 100)
|
||||||
|
|
||||||
|
// Только незавершённые (ACCEPTED/ACTIVE) с истёкшим окном и без soft-delete, по возрастанию end.
|
||||||
|
assertEquals(listOf(overdueActive.id, overdueAccepted.id), expirable.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional
|
||||||
|
fun `expirable query honours the batch size limit`() {
|
||||||
|
val oldest = request(status = RequestStatus.ACTIVE, end = now.minusHours(3))
|
||||||
|
val middle = request(status = RequestStatus.ACCEPTED, end = now.minusHours(2))
|
||||||
|
val newest = request(status = RequestStatus.ACTIVE, end = now.minusHours(1))
|
||||||
|
requestRepository.saveAllAndFlush(listOf(newest, oldest, middle))
|
||||||
|
|
||||||
|
val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 2)
|
||||||
|
|
||||||
|
assertEquals(listOf(oldest.id, middle.id), expirable.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(
|
||||||
|
status: RequestStatus,
|
||||||
|
end: LocalDateTime,
|
||||||
|
begin: LocalDateTime = end.minusDays(1),
|
||||||
|
deletedAt: LocalDateTime? = null,
|
||||||
|
): RequestEntity {
|
||||||
|
val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"
|
||||||
|
return RequestEntity(
|
||||||
|
id = UUID.randomUUID(),
|
||||||
|
name = "Тест1",
|
||||||
|
geometry = geometry,
|
||||||
|
remainingGeometry = geometry,
|
||||||
|
geometryArea = 1.0,
|
||||||
|
remainingArea = 1.0,
|
||||||
|
importance = 10.0,
|
||||||
|
beginDateTime = begin,
|
||||||
|
endDateTime = end,
|
||||||
|
surveyType = RequestSurveyType.OPTICS,
|
||||||
|
status = status,
|
||||||
|
createdAt = begin.minusDays(1),
|
||||||
|
updatedAt = begin.minusDays(1),
|
||||||
|
deletedAt = deletedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-19
@@ -6,6 +6,8 @@ import org.junit.jupiter.api.Assertions.assertNotNull
|
|||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.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())
|
||||||
|
|||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
package org.nstart.dep265.requestservice.service
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.mockito.Mockito.mock
|
|
||||||
import org.mockito.Mockito.mockingDetails
|
|
||||||
import org.nstart.dep265.requestservice.entity.RequestEntity
|
|
||||||
import org.nstart.dep265.requestservice.entity.RequestStatus
|
|
||||||
import org.nstart.dep265.requestservice.entity.RequestSurveyType
|
|
||||||
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class RequestCompletedOutboxServiceTest {
|
|
||||||
private val objectMapper = jacksonObjectMapper()
|
|
||||||
.registerModule(JavaTimeModule())
|
|
||||||
.findAndRegisterModules()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `request completed payload contains required fields`() {
|
|
||||||
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
|
||||||
val service = RequestCompletedOutboxService(
|
|
||||||
outboxEventRepository = outboxEventRepository,
|
|
||||||
objectMapper = objectMapper,
|
|
||||||
geometryService = GeometryService(),
|
|
||||||
)
|
|
||||||
val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846")
|
|
||||||
val completedAt = LocalDateTime.parse("2026-01-01T10:15:30")
|
|
||||||
val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00")
|
|
||||||
val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00")
|
|
||||||
val request = RequestEntity(
|
|
||||||
id = requestId,
|
|
||||||
name = "Тест1",
|
|
||||||
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
|
|
||||||
remainingGeometry = "POLYGON EMPTY",
|
|
||||||
geometryArea = 1.0,
|
|
||||||
remainingArea = 0.0,
|
|
||||||
importance = 10.0,
|
|
||||||
beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
|
|
||||||
endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"),
|
|
||||||
surveyType = RequestSurveyType.OPTICS,
|
|
||||||
status = RequestStatus.COMPLETED,
|
|
||||||
lastMatchedAt = matchedAt,
|
|
||||||
completedAt = completedAt,
|
|
||||||
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
|
|
||||||
updatedAt = completedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
service.createRequestCompletedIfAbsent(request, eventCreatedAt)
|
|
||||||
|
|
||||||
val insertInvocation = mockingDetails(outboxEventRepository).invocations.single {
|
|
||||||
invocation -> invocation.method.name == "insertIfAbsent"
|
|
||||||
}
|
|
||||||
val payload = objectMapper.readTree(insertInvocation.arguments[4] as String)
|
|
||||||
assertEquals(requestId.toString(), payload["requestId"].asText())
|
|
||||||
assertEquals("COMPLETED", payload["status"].asText())
|
|
||||||
assertNotNull(payload["completedAt"])
|
|
||||||
assertEquals(100.0, payload["coveragePercent"].asDouble())
|
|
||||||
assertNotNull(payload["matchedAt"])
|
|
||||||
assertNotNull(payload["eventCreatedAt"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
package org.nstart.dep265.requestservice.service
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentMatchers.any
|
||||||
|
import org.mockito.ArgumentMatchers.anyInt
|
||||||
|
import org.mockito.Mockito.doReturn
|
||||||
|
import org.mockito.Mockito.mock
|
||||||
|
import org.mockito.Mockito.mockingDetails
|
||||||
|
import org.mockito.Mockito.never
|
||||||
|
import org.mockito.Mockito.times
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
|
import org.mockito.Mockito.verifyNoInteractions
|
||||||
|
import org.nstart.dep265.requestservice.config.RequestExpiryProperties
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestEntity
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestSurveyType
|
||||||
|
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
|
||||||
|
import org.nstart.dep265.requestservice.repository.RequestRepository
|
||||||
|
import org.springframework.transaction.TransactionStatus
|
||||||
|
import org.springframework.transaction.support.SimpleTransactionStatus
|
||||||
|
import org.springframework.transaction.support.TransactionCallback
|
||||||
|
import org.springframework.transaction.support.TransactionOperations
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class RequestExpiryServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `expires overdue accepted and active requests and emits terminal events`() {
|
||||||
|
val requestRepository = mock(RequestRepository::class.java)
|
||||||
|
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||||
|
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
|
||||||
|
|
||||||
|
val expiredAccepted = overdueRequest(status = RequestStatus.ACCEPTED)
|
||||||
|
val expiredActive = overdueRequest(status = RequestStatus.ACTIVE)
|
||||||
|
doReturn(listOf(expiredAccepted, expiredActive))
|
||||||
|
.`when`(requestRepository)
|
||||||
|
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||||
|
|
||||||
|
val expiredCount = service.expireOverdue()
|
||||||
|
|
||||||
|
assertEquals(2, expiredCount)
|
||||||
|
assertEquals(RequestStatus.EXPIRED, expiredAccepted.status)
|
||||||
|
assertEquals(RequestStatus.EXPIRED, expiredActive.status)
|
||||||
|
verify(requestRepository).save(expiredAccepted)
|
||||||
|
verify(requestRepository).save(expiredActive)
|
||||||
|
|
||||||
|
// По каждой истёкшей заявке — терминальное событие в outbox для geoportal.
|
||||||
|
val terminalInserts = mockingDetails(outboxEventRepository).invocations
|
||||||
|
.filter { invocation -> invocation.method.name == "insertIfAbsent" }
|
||||||
|
assertEquals(2, terminalInserts.size)
|
||||||
|
assertTrue(terminalInserts.all { invocation -> invocation.arguments[1] == "REQUEST_TERMINAL_STATUS" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps fetching while a full batch is returned`() {
|
||||||
|
val requestRepository = mock(RequestRepository::class.java)
|
||||||
|
val service = expiryService(
|
||||||
|
requestRepository,
|
||||||
|
mock(OutboxEventRepository::class.java),
|
||||||
|
RequestExpiryProperties(batchSize = 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
val first = overdueRequest(status = RequestStatus.ACTIVE)
|
||||||
|
val second = overdueRequest(status = RequestStatus.ACCEPTED)
|
||||||
|
doReturn(listOf(first), listOf(second), emptyList<RequestEntity>())
|
||||||
|
.`when`(requestRepository)
|
||||||
|
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||||
|
|
||||||
|
val expiredCount = service.expireOverdue()
|
||||||
|
|
||||||
|
assertEquals(2, expiredCount)
|
||||||
|
verify(requestRepository, times(3))
|
||||||
|
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty sweep returns zero and emits nothing`() {
|
||||||
|
val requestRepository = mock(RequestRepository::class.java)
|
||||||
|
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||||
|
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
|
||||||
|
|
||||||
|
doReturn(emptyList<RequestEntity>())
|
||||||
|
.`when`(requestRepository)
|
||||||
|
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||||
|
|
||||||
|
val expiredCount = service.expireOverdue()
|
||||||
|
|
||||||
|
assertEquals(0, expiredCount)
|
||||||
|
verify(requestRepository, never()).save(any(RequestEntity::class.java))
|
||||||
|
verifyNoInteractions(outboxEventRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `request still inside its window is not expired`() {
|
||||||
|
val requestRepository = mock(RequestRepository::class.java)
|
||||||
|
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||||
|
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
|
||||||
|
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
val inWindow = request(
|
||||||
|
status = RequestStatus.ACTIVE,
|
||||||
|
beginDateTime = now.minusDays(1),
|
||||||
|
endDateTime = now.plusDays(1),
|
||||||
|
)
|
||||||
|
doReturn(listOf(inWindow))
|
||||||
|
.`when`(requestRepository)
|
||||||
|
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||||
|
|
||||||
|
val expiredCount = service.expireOverdue()
|
||||||
|
|
||||||
|
assertEquals(0, expiredCount)
|
||||||
|
assertEquals(RequestStatus.ACTIVE, inWindow.status)
|
||||||
|
verify(requestRepository, never()).save(any(RequestEntity::class.java))
|
||||||
|
verifyNoInteractions(outboxEventRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled scheduler does not sweep`() {
|
||||||
|
val requestRepository = mock(RequestRepository::class.java)
|
||||||
|
val service = expiryService(
|
||||||
|
requestRepository,
|
||||||
|
mock(OutboxEventRepository::class.java),
|
||||||
|
RequestExpiryProperties(enabled = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
service.expireOverdueScheduledBatch()
|
||||||
|
|
||||||
|
verify(requestRepository, never())
|
||||||
|
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expiryService(
|
||||||
|
requestRepository: RequestRepository,
|
||||||
|
outboxEventRepository: OutboxEventRepository,
|
||||||
|
properties: RequestExpiryProperties,
|
||||||
|
): RequestExpiryService {
|
||||||
|
val geometryService = GeometryService()
|
||||||
|
return RequestExpiryService(
|
||||||
|
requestRepository = requestRepository,
|
||||||
|
requestLifecycleService = RequestLifecycleService(geometryService),
|
||||||
|
requestStatusOutboxService = RequestStatusOutboxService(
|
||||||
|
outboxEventRepository = outboxEventRepository,
|
||||||
|
objectMapper = jacksonObjectMapper()
|
||||||
|
.registerModule(JavaTimeModule())
|
||||||
|
.findAndRegisterModules(),
|
||||||
|
geometryService = geometryService,
|
||||||
|
),
|
||||||
|
requestExpiryProperties = properties,
|
||||||
|
transactionOperations = ImmediateTransactionOperations(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun overdueRequest(status: RequestStatus): RequestEntity {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
return request(
|
||||||
|
status = status,
|
||||||
|
beginDateTime = now.minusDays(10),
|
||||||
|
endDateTime = now.minusDays(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(
|
||||||
|
status: RequestStatus,
|
||||||
|
beginDateTime: LocalDateTime,
|
||||||
|
endDateTime: LocalDateTime,
|
||||||
|
): RequestEntity {
|
||||||
|
val geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||||
|
return RequestEntity(
|
||||||
|
id = UUID.randomUUID(),
|
||||||
|
name = "Тест1",
|
||||||
|
geometry = geometry,
|
||||||
|
remainingGeometry = geometry,
|
||||||
|
geometryArea = 1.0,
|
||||||
|
remainingArea = 1.0,
|
||||||
|
importance = 10.0,
|
||||||
|
beginDateTime = beginDateTime,
|
||||||
|
endDateTime = endDateTime,
|
||||||
|
surveyType = RequestSurveyType.OPTICS,
|
||||||
|
status = status,
|
||||||
|
createdAt = beginDateTime.minusDays(1),
|
||||||
|
updatedAt = beginDateTime.minusDays(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрирует matcher, но возвращает non-null заглушку — иначе any() на non-null LocalDateTime
|
||||||
|
// в Kotlin отдаёт null и ломает стек матчеров Mockito.
|
||||||
|
private fun anyLocalDateTime(): LocalDateTime {
|
||||||
|
any(LocalDateTime::class.java)
|
||||||
|
return LocalDateTime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ImmediateTransactionOperations : TransactionOperations {
|
||||||
|
override fun <T> execute(action: TransactionCallback<T>): T {
|
||||||
|
val status: TransactionStatus = SimpleTransactionStatus()
|
||||||
|
return action.doInTransaction(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -50,6 +50,7 @@ class RequestServiceTest {
|
|||||||
requestCellRepository = requestCellRepository,
|
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,
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package org.nstart.dep265.requestservice.service
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito.mock
|
||||||
|
import org.mockito.Mockito.mockingDetails
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestEntity
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestStatus
|
||||||
|
import org.nstart.dep265.requestservice.entity.RequestSurveyType
|
||||||
|
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class RequestStatusOutboxServiceTest {
|
||||||
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
.registerModule(JavaTimeModule())
|
||||||
|
.findAndRegisterModules()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `completed terminal payload contains required fields`() {
|
||||||
|
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||||
|
val service = service(outboxEventRepository)
|
||||||
|
val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846")
|
||||||
|
val completedAt = LocalDateTime.parse("2026-01-01T10:15:30")
|
||||||
|
val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00")
|
||||||
|
val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00")
|
||||||
|
val request = RequestEntity(
|
||||||
|
id = requestId,
|
||||||
|
name = "Тест1",
|
||||||
|
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||||
|
remainingGeometry = "POLYGON EMPTY",
|
||||||
|
geometryArea = 1.0,
|
||||||
|
remainingArea = 0.0,
|
||||||
|
importance = 10.0,
|
||||||
|
beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
|
||||||
|
endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"),
|
||||||
|
surveyType = RequestSurveyType.OPTICS,
|
||||||
|
status = RequestStatus.COMPLETED,
|
||||||
|
lastMatchedAt = matchedAt,
|
||||||
|
completedAt = completedAt,
|
||||||
|
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
|
||||||
|
updatedAt = completedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
service.createTerminalIfAbsent(request, eventCreatedAt)
|
||||||
|
|
||||||
|
val payload = objectMapper.readTree(insertedPayload(outboxEventRepository))
|
||||||
|
assertEquals(requestId.toString(), payload["requestId"].asText())
|
||||||
|
assertEquals("COMPLETED", payload["status"].asText())
|
||||||
|
assertEquals(completedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java))
|
||||||
|
assertEquals(100.0, payload["coveragePercent"].asDouble())
|
||||||
|
assertNotNull(payload["matchedAt"])
|
||||||
|
assertNotNull(payload["eventCreatedAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `expired terminal payload carries expired status and partial coverage`() {
|
||||||
|
val outboxEventRepository = mock(OutboxEventRepository::class.java)
|
||||||
|
val service = service(outboxEventRepository)
|
||||||
|
val requestId = UUID.fromString("9c2f0c4b-3a51-4e2d-9d8a-1f4b2c6e7a01")
|
||||||
|
val eventCreatedAt = LocalDateTime.parse("2026-02-01T00:00:00")
|
||||||
|
val request = RequestEntity(
|
||||||
|
id = requestId,
|
||||||
|
name = "Тест1",
|
||||||
|
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||||
|
remainingGeometry = "POLYGON((0 0, 0.5 0, 0.5 1, 0 1, 0 0))",
|
||||||
|
geometryArea = 1.0,
|
||||||
|
remainingArea = 0.5,
|
||||||
|
importance = 10.0,
|
||||||
|
beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"),
|
||||||
|
endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"),
|
||||||
|
surveyType = RequestSurveyType.OPTICS,
|
||||||
|
status = RequestStatus.EXPIRED,
|
||||||
|
completedAt = null,
|
||||||
|
createdAt = LocalDateTime.parse("2025-12-31T00:00:00"),
|
||||||
|
updatedAt = eventCreatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
service.createTerminalIfAbsent(request, eventCreatedAt)
|
||||||
|
|
||||||
|
val payload = objectMapper.readTree(insertedPayload(outboxEventRepository))
|
||||||
|
assertEquals("EXPIRED", payload["status"].asText())
|
||||||
|
// completedAt отсутствует у EXPIRED → occurredAt = момент фиксации истечения.
|
||||||
|
assertEquals(eventCreatedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java))
|
||||||
|
assertEquals(50.0, payload["coveragePercent"].asDouble())
|
||||||
|
assertTrue(payload["matchedAt"].isNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun service(outboxEventRepository: OutboxEventRepository): RequestStatusOutboxService {
|
||||||
|
return RequestStatusOutboxService(
|
||||||
|
outboxEventRepository = outboxEventRepository,
|
||||||
|
objectMapper = objectMapper,
|
||||||
|
geometryService = GeometryService(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertedPayload(outboxEventRepository: OutboxEventRepository): String {
|
||||||
|
val insertInvocation = mockingDetails(outboxEventRepository).invocations.single { invocation ->
|
||||||
|
invocation.method.name == "insertIfAbsent"
|
||||||
|
}
|
||||||
|
return insertInvocation.arguments[4] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -288,7 +288,7 @@ class RouteMatchingServiceTest {
|
|||||||
invocation -> invocation.method.name == "insertIfAbsent"
|
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())
|
||||||
|
|||||||
Reference in New Issue
Block a user