Files
dc-observatio/docs/аудит-сервис-заданий.md
T
Дмитрий Соловьев c1259c3105 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) — до роста требований к точности.
2026-06-06 23:47:19 +03:00

29 KiB
Raw Blame History

Аудит сервиса заданий — 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.

На каждый паспорт маршрута:

  1. Кандидаты выбираются по окну времени + статусу (ACCEPTED/ACTIVE, не удалённые) — findRequestIdsForRoute.
  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, по приоритету сверху вниз:

Условие Статус
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-group pcp-request-service-route).
  • OutboxSELECT … 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): KafkaConsumerConfigDefaultErrorHandler + 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). Сейчас эмитится только COMPLETEDpcp.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 + 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 и тесты; покрытие — отношение площадей).