# Аудит сервиса заданий — 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`, контракт из `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 и тесты; покрытие — отношение площадей).