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) — до роста требований к точности.
29 KiB
Аудит сервиса заданий — pcp-request-service
Дата: 2026-06-06. Автор: Claude (Opus 4.8).
Охват: сервис pcp-request-service («сервис заданий») — задача #26.
Метод: статический разбор кода (контроллеры, Kafka-listener, сервисы сопоставления и жизненного
цикла, outbox, earth-grid), схема БД и Flyway-миграция, конфиг в config-repo, обзор тестов.
Парные документы: общий аудит-проекта-2026-06-06.md,
карты PCP_ARCHITECTURE.md и
PCP_SERVICE_INTERACTIONS.md.
Стык с сервисом паспорта маршрута (pcp-route-processing-service) — отдельная задача
#28, здесь только обозначен.
Сводка по состоянию
Базовое здоровье — хорошее. Сервис держит жизненный цикл заявок на съёмку, ведёт учёт отснятой
территории геометрическим вычитанием и сопоставляет приходящие паспорта маршрутов с заявками.
Конкурентность и работа в нескольких инстансах продуманы: 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, CellsController, GridManagementController.
Доменная модель и БД
Источник: 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, геометрия — GeometryService.
На каждый паспорт маршрута:
- Кандидаты выбираются по окну времени + статусу (
ACCEPTED/ACTIVE, не удалённые) — findRequestIdsForRoute. - Идемпотентность:
existsByRequestIdAndRouteId+request_route_matches UNIQUE+ обработка гонки вставки. - На каждую заявку — своя транзакция с
findByIdForUpdate(row-lock): маршрут пересекается сremaining_geometry(не с исходной), остаток уменьшаетсяdifference, пересчитываетсяremaining_area. coveredPercent = (geometry_area − remaining_area) / geometry_area × 100.
Поскольку учёт идёт по остатку, перекрывающиеся маршруты не задваиваются — второй маршрут засчитывает только новую площадь. Это корректная кумулятивная модель покрытия.
Ограничения учёта вынесены в находки RS2 (игнор совместимости аппарата), RS5 (планарная площадь в градусах), RS6 (earth-grid — это важность, а не покрытие).
Жизненный цикл и закрытие заданий
Вся логика — пересчёт статуса «с нуля» в RequestLifecycleService.refreshStatus, по приоритету сверху вниз:
| Условие | Статус |
|---|---|
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),
который затем публикуется
OutboxPublisherService
в 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-grouppcp-request-service-route). - Outbox —
SELECT … FOR UPDATE SKIP LOCKED+UNIQUE(event_type, aggregate_id)→ параллельные инстансы не публикуют эвент дважды (OutboxEventRepository). - Пересборка сетки —
pg_try_advisory_xact_lock(GridRebuildLockService) → ребилдит только один инстанс. - Создание заявки —
existsById+ PK + обработка23505→ дубликат не пройдёт при гонке.
Семантика доставки — at-least-once; повторная обработка маршрута безопасна за счёт дедупа.
После #31 «ядовитое» сообщение уходит в DLQ (DefaultErrorHandler + bounded retry), а не блокирует
партицию. Outbox FAILED после #35 ретраится с backoff и пределом попыток. Параллелизм потребления
маршрутов ограничен числом партиций топика.
Находки
RS1 (высокий). Нет планировщика истечения — закрытие «по времени» не срабатывает — ✅ устранено (#29)
Файлы: RequestLifecycleService.kt, 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 —
@Scheduled-воркер переводит просроченные ACCEPTED/ACTIVE в EXPIRED, забирая заявки батчами через
findExpirableForUpdateSkipLocked (FOR UPDATE SKIP LOCKED + верхний лимит батчей — безопасно при нескольких
инстансах). refreshStatus теперь вызывается и в createRequest, поэтому статус приёма отражает окно
(ACCEPTED/ACTIVE/EXPIRED). Конфиг — pcp.request.expiry.*. Тесты: RequestExpiryServiceTest,
RequestRepositoryJpaTest.
RS2 (высокий). Матчинг игнорирует совместимость аппарата
Файлы: RouteMatchingService.kt, 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
Listener ловит исключение, логирует и перебрасывает его. DLQ и кастомный CommonErrorHandler
не настроены — работают дефолты Spring Kafka. Невалидное («ядовитое») сообщение маршрута будет
ретраиться бесконечно и заблокирует партицию. У парного pcp-route-processing-service DLQ есть,
здесь — нет.
Рекомендация: DefaultErrorHandler с ограниченными ретраями и DeadLetterPublishingRecoverer
(топик вида pcp.route.georeference-dlq.v1); невалидные по контракту сообщения отправлять в DLQ,
а не зацикливать.
Готово (#31): KafkaConsumerConfig —
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
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
Площади считаются 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, 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, OutboxEventRepository.kt, RequestStatusOutboxService.kt
Потребитель завершения заявок — внешний сервис geoportal (в этом монорепо его нет, поэтому в
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
(по достижению покрытия) и RequestExpiryService
(по истечению срока). 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+ DLQpcp.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 и тесты; покрытие — отношение площадей).