# План работ: доведение `pcp-route-processing-service` / route-passport до production-ready состояния Дата подготовки: 2026-04-28 Источник анализа: архив `pcp.zip`, сервис `services/pcp-route-processing-service`, shared library `libs/pcp-types-lib`. --- ## 0. Цель работ Нужно усилить текущую реализацию обработки паспорта маршрута так, чтобы сервис был готов к production-сценарию: 1. входной Kafka offset не должен коммититься до надежного сохранения результата обработки; 2. принятый маршрут должен сохраняться в БД вместе с геометрией и параметрами; 3. публикация downstream-событий должна идти через outbox, а не напрямую из Kafka listener; 4. ошибки должны быть классифицированы и обрабатываться через bounded retry + DLQ/DLT; 5. ручную GeoJSON → WKT конвертацию нужно заменить на библиотечную; 6. текущая бизнес-семантика `routeStatus = PROCESSED` остается без изменений; 7. flow остается Kafka-only, BPM/Camunda в рамках этой задачи не добавляется. --- ## 1. Текущая реализация, от которой отталкиваемся ### 1.1. Текущий поток ```text pcp.request.survey-georeference.v1 ↓ RoutePassportMessageListener.listen(message: String) ↓ RoutePassportJsonParserService.parse(message) ↓ ParsedRoutePassportMessage(RoutePassportDto + trace/correlation metadata) ↓ RoutePassportKafkaPublisherService.publish(parsedMessage) ├── pcp.route.in.v1 / ModeStatusChangedEvent └── pcp.route.georeference.v1 / RouteGeoRefEvent ``` ### 1.2. Ключевые текущие классы | Файл/класс | Текущая роль | |---|---| | `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListener.kt` | Kafka listener входного topic `pcp.request.survey-georeference.v1`. Сейчас парсит сообщение и сразу вызывает publisher. | | `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserService.kt` | Парсит raw envelope в `RoutePassportDto`; вручную конвертирует GeoJSON geometry в WKT. | | `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherService.kt` | Публикует один и тот же `RoutePassportDto` в два output topic. Сейчас publish асинхронный и ошибка только логируется. | | `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfig.kt` | Явно создает `ConsumerFactory` и `ConcurrentKafkaListenerContainerFactory`. | | `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaProducerConfig.kt` | Явно создает `ProducerFactory` и `KafkaTemplate`. | | `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaTopicsProperties.kt` | Типизированные имена input/output topic. | | `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RoutePassportMessageDTO.kt` | Shared DTO: `RoutePassportDto`, `AngleRangeDto`. | | `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/ParsedRoutePassportMessageDTO.kt` | Shared DTO: `ParsedRoutePassportMessage`, metadata. | | `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RawPassportJsonDTO.kt` | Raw DTO входного envelope/STAC-like паспорта. В рамках этой задачи не переносится. | | `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt` | Общая Kafka envelope-модель. В рамках этой задачи не меняется. | | `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/PcpKafkaEvent.kt` | Типы Kafka-событий, включая `RouteGeoRefEvent` и `ModeStatusChangedEvent`. | ### 1.3. Главные текущие проблемы | Проблема | Почему это риск | |---|---| | `enable-auto-commit: true` в `config-repo/pcp-route-processing-service.yaml` | Offset может быть закоммичен до надежной обработки сообщения. | | Listener сразу вызывает Kafka publisher | Если publish упал, входное сообщение уже могло быть считано/закоммичено. | | Publisher отправляет два события асинхронно и только логирует ошибку | Возможна частичная публикация: один downstream topic получил событие, второй нет. | | Нет БД и outbox | Невозможно надежно восстановить публикацию downstream-событий после сбоя Kafka/сервиса. | | Нет явного DLQ/DLT и bounded retry | Bad input может зависнуть в бесконечных retry или обрабатываться неявным default behavior Spring Kafka. | | Ручная GeoJSON → WKT конвертация | Риск некорректной геометрии, слабая валидация, ручная поддержка Polygon/MultiPolygon. | | `routeStatus` всегда `PROCESSED` | Это остается осознанным текущим поведением. Не исправляем в этой задаче. | --- ## 2. Принятые решения по scope ### 2.1. Делаем | № | Решение | |---:|---| | 1 | Перевести Kafka consumer на controlled commit: offset подтверждается только после успешного DB commit. | | 2 | Настроить bounded retry + DLQ/DLT для входных сообщений. | | 3 | Добавить БД для `pcp-route-processing-service`. | | 4 | Сохранять принятый маршрут, геометрию, параметры и статус в БД. | | 5 | Реализовать outbox через эту БД. | | 6 | Убрать прямую downstream-публикацию из listener. | | 7 | Публиковать `ModeStatusChangedEvent` и `RouteGeoRefEvent` из outbox worker. | | 8 | Добавить producer reliability настройки: `acks=all`, retries, idempotence. | | 9 | Добавить классификацию ошибок: `SUCCESS`, `RETRYABLE`, `BUSINESS/CONTRACT`, `FATAL`. | | 10 | Заменить ручной GeoJSON → WKT converter на библиотечный подход. | | 11 | Оставить flow Kafka-only. BPM/Camunda не добавлять. | | 12 | Добавить минимальные тесты под измененную логику. | ### 2.2. Не делаем в этой задаче | Пункт | Решение | Риск, который остается | |---|---|---| | Чтение `routeStatus` из входного сообщения | Не делаем. Оставляем `PROCESSED` как сейчас. | Если внешний контур начнет присылать `FAILED`, `CANCELED`, `DROPPED`, сервис это проигнорирует. | | Замена Kafka key с `traceId` на `routeId`/`correlationId` | Не делаем. | Partitioning/ordering остаются привязанными к traceId, а не к business key. | | Fallback `traceId`/`correlationId` | Не делаем. | Пустые trace/correlation значения могут пройти дальше. | | README/fixtures | Не трогаем. | Документация и fixtures могут остаться несогласованными с кодом. | | E2E serialization test producer → consumers | Не делаем отдельным пунктом. | Контракт producer/consumer не будет полностью проверен end-to-end. | | `RouteProcessingTestController` | Не трогаем. | Тестовый endpoint остается в main-коде. | | `OpenApiConfig`/springdoc | Не трогаем. | Лишняя REST/OpenAPI инфраструктура остается. | | Перенос raw DTO из shared lib | Не делаем. | Shared lib продолжает содержать внутренний raw input contract route-processing-service. | | Упрощение `KafkaEvent`/ClassGraph | Не делаем. | Существующая абстракция остается как есть. | | Упрощение Kafka config через `KafkaProperties` | Не делаем. | Но точечно добавляем нужные reliability properties в текущий custom config. | | Изменение `KafkaMessage` | Не делаем. | `source = "Example"` и mutable поле остаются в shared contract. | | Полные Kafka transactions | Не делаем. | Достигаем надежности через DB outbox и at-least-once. | | Camunda/BPM flow | Не делаем. | Сервис остается Kafka-only. | --- ## 3. Целевая архитектура ### 3.1. Целевой поток обработки input message ```text pcp.request.survey-georeference.v1 ↓ RoutePassportMessageListener ↓ RoutePassportJsonParserService ↓ RouteGeometryConverter ↓ RoutePassportIngestionService @Transactional ├── UPSERT route_passports └── INSERT/UPSERT route_passport_outbox x2 ├── ModeStatusChangedEvent -> pcp.route.in.v1 └── RouteGeoRefEvent -> pcp.route.georeference.v1 ↓ Kafka offset ack/commit only after DB commit ``` ### 3.2. Целевой поток публикации outbox ```text route_passport_outbox PENDING rows ↓ RoutePassportOutboxWorker @Scheduled ↓ RoutePassportOutboxService.claimDueBatch() ↓ RoutePassportKafkaPublisherService.publish(outboxRow) ↓ Kafka output topic ↓ mark PUBLISHED or schedule retry / mark FAILED ``` ### 3.3. Целевой flow ошибок ```text Invalid JSON / invalid contract / unsupported geometry ↓ RoutePassportContractException ↓ DefaultErrorHandler non-retryable ↓ DLQ/DLT topic Temporary DB error during ingest ↓ Retry with bounded backoff ↓ Success OR DLQ/DLT after max attempts Kafka output publish error in outbox worker ↓ outbox row remains retryable ↓ next_attempt_at + attempts ↓ PUBLISHED OR FAILED after max attempts ``` --- ## 4. Database naming and ownership ### 4.1. Рекомендуемое имя БД Использовать БД: ```text pcp_route_processing ``` Причина: БД принадлежит сервису `pcp-route-processing-service`, а не отдельному DTO. Это соответствует подходу service-owned database. Человеческое описание: ```text Route Processing DB — БД хранения принятых route passport и outbox событий route-processing-service. ``` ### 4.2. Не использовать ```text pcp-route-passport-bd ``` Причины: 1. дефисы неудобны для PostgreSQL database identifiers; 2. `bd` лучше не использовать, стандартнее `db`, но в имени БД суффикс вообще не нужен; 3. БД должна называться по сервису/домейну, а не по техническому слову “passport DTO”. --- ## 5. Новая структура пакетов Текущий base package: ```text space.nstart.pcp.routepassportconsumer ``` Предлагаемая структура: ```text space.nstart.pcp.routepassportconsumer ├── config │ ├── KafkaConsumerConfig.kt │ ├── KafkaProducerConfig.kt │ ├── KafkaTopicsProperties.kt │ ├── RoutePassportOutboxProperties.kt │ └── RoutePassportStorageProperties.kt # optional, если нужны storage-specific настройки ├── listener │ └── RoutePassportMessageListener.kt ├── service │ ├── RoutePassportJsonParserService.kt │ ├── RouteGeometryConverter.kt │ ├── RoutePassportIngestionService.kt │ ├── RoutePassportKafkaPublisherService.kt │ ├── RoutePassportOutboxService.kt │ └── RoutePassportOutboxWorker.kt ├── entity │ ├── RoutePassportEntity.kt │ └── RoutePassportOutboxEntity.kt ├── repository │ ├── RoutePassportRepository.kt │ └── RoutePassportOutboxRepository.kt ├── exception │ ├── RoutePassportContractException.kt │ ├── RoutePassportRetryableException.kt # optional │ └── RoutePassportFatalException.kt # optional └── dto └── RoutePassportOutboxPayload.kt # optional, если нужен внутренний DTO outbox payload ``` Правило: новые классы добавлять только если они реально отделяют ответственность. Не создавать лишние interfaces/factories/strategies. --- # 6. Детальный план реализации --- ## Шаг 1. Подключить БД, JPA и Flyway к `pcp-route-processing-service` ### 1.1. Шаг реализации Добавить поддержку PostgreSQL/JPA/Flyway в `pcp-route-processing-service` по аналогии с `pcp-request-service`. ### 1.2. Вход шага Текущий `pcp-route-processing-service` без БД. Файлы: ```text services/pcp-route-processing-service/build.gradle.kts services/pcp-route-processing-service/src/main/resources/application.yml config-repo/pcp-route-processing-service.yaml ``` ### 1.3. Выход шага Сервис умеет стартовать с datasource, JPA validation и Flyway migrations. ### 1.4. Какие существующие классы участвуют На этом шаге классы бизнес-логики не меняются. ### 1.5. Нужно ли вводить новую модель Нет. ### 1.6. Слой `adapter / infrastructure` ### 1.7. Изменения в `build.gradle.kts` Добавить plugin: ```kotlin kotlin("plugin.jpa") ``` Добавить dependencies: ```kotlin implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.flywaydb:flyway-database-postgresql") implementation("org.locationtech.jts:jts-core:1.19.0") runtimeOnly("org.postgresql:postgresql") ``` `org.locationtech.jts:jts-core:1.19.0` уже используется в `pcp-request-service`, поэтому это не новая для проекта библиотека. ### 1.8. Изменения в `config-repo/pcp-route-processing-service.yaml` Добавить: ```yaml spring: datasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_route_processing username: postgres password: password jpa: hibernate: ddl-auto: validate properties: hibernate: format_sql: true flyway: enabled: true baseline-on-migrate: true locations: classpath:db/migration ``` ### 1.9. Acceptance criteria 1. Gradle module компилируется с JPA/Flyway dependencies. 2. При старте сервис ищет migrations в `classpath:db/migration`. 3. Hibernate не создает schema сам, а валидирует ее через `ddl-auto: validate`. --- ## Шаг 2. Создать таблицу `route_passports` ### 2.1. Шаг реализации Добавить Flyway migration: ```text services/pcp-route-processing-service/src/main/resources/db/migration/V1__create_route_passports.sql ``` ### 2.2. Вход шага `RoutePassportDto`: ```text libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RoutePassportMessageDTO.kt ``` ### 2.3. Выход шага Таблица `route_passports` хранит нормализованный паспорт маршрута, геометрию, параметры, metadata входного Kafka record и raw payload для аудита. ### 2.4. Какие существующие классы участвуют | Класс | Как участвует | |---|---| | `RoutePassportDto` | Источник полей для таблицы. | | `AngleRangeDto` | Источник `roll_angle_min/max`, `visir_angle_min/max`. | | `SurveyModeStatus` | Источник `route_status`; пока всегда `PROCESSED`. | | `ParsedRoutePassportMessage` | Источник trace/correlation metadata. | ### 2.5. Нужно ли вводить новую модель Да: ```text RoutePassportEntity RoutePassportRepository ``` ### 2.6. Слой `adapter / storage` ### 2.7. Рекомендуемый SQL ```sql CREATE TABLE IF NOT EXISTS route_passports ( route_id UUID PRIMARY KEY, route_name_full TEXT NOT NULL, route_name_short VARCHAR(255) NOT NULL, ka_short VARCHAR(64) NOT NULL, ka_full VARCHAR(255) NOT NULL, orbit_number BIGINT NOT NULL, orbit_state VARCHAR(64) NOT NULL, interval_begin TIMESTAMP NOT NULL, interval_end TIMESTAMP NOT NULL, roll_angle_min NUMERIC NOT NULL, roll_angle_max NUMERIC NOT NULL, visir_angle_min NUMERIC NOT NULL, visir_angle_max NUMERIC NOT NULL, resolution_range NUMERIC NOT NULL, resolution_azimuth NUMERIC NOT NULL, polarisation JSONB NOT NULL, processing_level VARCHAR(64) NOT NULL, geometry_wkt TEXT NOT NULL, route_status VARCHAR(32) NOT NULL, trace_id VARCHAR(255) NULL, correlation_id VARCHAR(255) NULL, source_topic VARCHAR(255) NULL, source_partition INTEGER NULL, source_offset BIGINT NULL, source_key VARCHAR(255) NULL, raw_payload JSONB NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, version BIGINT NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_route_passports_interval ON route_passports (interval_begin, interval_end); CREATE INDEX IF NOT EXISTS idx_route_passports_ka_orbit ON route_passports (ka_short, orbit_number); CREATE INDEX IF NOT EXISTS idx_route_passports_status ON route_passports (route_status); CREATE UNIQUE INDEX IF NOT EXISTS uk_route_passports_source_record ON route_passports (source_topic, source_partition, source_offset) WHERE source_topic IS NOT NULL AND source_partition IS NOT NULL AND source_offset IS NOT NULL; ``` ### 2.8. Почему `geometry_wkt TEXT`, а не PostGIS geometry Сейчас downstream `pcp-request-service` получает geometry как WKT string и уже сам выполняет route matching. В `pcp-route-processing-service` пока не планируются spatial queries. Поэтому PostGIS добавлять не нужно. Если позже появятся запросы вида “найти маршруты, пересекающие область”, тогда можно будет добавить отдельную migration с PostGIS geometry column. ### 2.9. Почему `polarisation JSONB` `polarisation` — список строк. Хранить как JSONB проще, чем вводить отдельную таблицу, потому что по нему пока не видно сложных запросов/джоинов. В entity можно хранить это поле как `String` с `@Column(columnDefinition = "jsonb")`, чтобы не тащить дополнительные Hibernate JSON types. ### 2.10. Acceptance criteria 1. Таблица хранит все поля `RoutePassportDto`. 2. `route_id` является primary key и idempotency key. 3. Есть исходная Kafka metadata для аудита. 4. Есть `raw_payload`, чтобы можно было восстановить, что реально пришло во входной topic. --- ## Шаг 3. Создать таблицу `route_passport_outbox` ### 3.1. Шаг реализации Добавить Flyway migration: ```text services/pcp-route-processing-service/src/main/resources/db/migration/V2__create_route_passport_outbox.sql ``` ### 3.2. Вход шага Текущие downstream-публикации из `RoutePassportKafkaPublisherService`: ```text pcp.route.in.v1 / PcpKafkaEvent.ModeStatusChangedEvent pcp.route.georeference.v1 / PcpKafkaEvent.RouteGeoRefEvent ``` ### 3.3. Выход шага Таблица outbox хранит pending Kafka-события для последующей надежной публикации. ### 3.4. Какие существующие классы участвуют | Класс | Как участвует | |---|---| | `RoutePassportKafkaPublisherService` | Будет использоваться outbox worker, а не listener. | | `KafkaTopicsProperties` | Источник имен output topics. | | `PcpKafkaEvent` | Тип события в outbox. | | `KafkaMessage` | Envelope payload, сохраняемый в outbox. | | `RoutePassportDto` | Data внутри KafkaMessage. | ### 3.5. Нужно ли вводить новую модель Да: ```text RoutePassportOutboxEntity RoutePassportOutboxRepository RoutePassportOutboxStatus RoutePassportOutboxService RoutePassportOutboxWorker ``` ### 3.6. Слой `adapter / storage`, `orchestration`, `adapter / messaging` ### 3.7. Рекомендуемый SQL ```sql CREATE TABLE IF NOT EXISTS route_passport_outbox ( id UUID PRIMARY KEY, route_id UUID NOT NULL, event_type VARCHAR(128) NOT NULL, topic VARCHAR(255) NOT NULL, message_key VARCHAR(255) NOT NULL, payload JSONB NOT NULL, headers JSONB NULL, status VARCHAR(32) NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 10, next_attempt_at TIMESTAMP NOT NULL, locked_until TIMESTAMP NULL, last_error TEXT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, published_at TIMESTAMP NULL, CONSTRAINT fk_route_passport_outbox_route FOREIGN KEY (route_id) REFERENCES route_passports(route_id) ON DELETE CASCADE, CONSTRAINT uk_route_passport_outbox_event UNIQUE (route_id, event_type) ); CREATE INDEX IF NOT EXISTS idx_route_passport_outbox_pending ON route_passport_outbox (status, next_attempt_at, created_at); CREATE INDEX IF NOT EXISTS idx_route_passport_outbox_locked ON route_passport_outbox (locked_until); ``` ### 3.8. Outbox statuses ```kotlin enum class RoutePassportOutboxStatus { PENDING, PUBLISHED, FAILED } ``` Не вводить `PUBLISHING` на первом этапе. Для защиты от конкурентных publisher’ов использовать `locked_until`. ### 3.9. Почему `UNIQUE(route_id, event_type)` Это нужно для идемпотентности. Сценарий: ```text 1. Listener сохранил route_passports + route_passport_outbox. 2. DB transaction успешно закоммитилась. 3. Сервис упал до Kafka offset ack. 4. Kafka отдаст тот же input message повторно. 5. Повторная обработка не должна создать дубль outbox-событий. ``` `route_id + event_type` защищает от повторного создания тех же downstream-событий. Ограничение: если в будущем один и тот же `route_id` может прийти как новая версия маршрута, понадобится добавить revision/version/source_event_id в idempotency key. В текущем контракте явной версии маршрута нет, поэтому `route_id` считаем business idempotency key. ### 3.10. Acceptance criteria 1. На один маршрут создаются максимум два outbox-события: `ModeStatusChangedEvent`, `RouteGeoRefEvent`. 2. Повторная обработка того же routeId не плодит дубли outbox rows. 3. Outbox rows можно безопасно читать несколькими instance’ами сервиса. --- ## Шаг 4. Добавить JPA entity и repositories ### 4.1. Шаг реализации Добавить storage-модели и repositories. ### 4.2. Вход шага Миграции из шагов 2–3. ### 4.3. Выход шага Код может сохранять маршрут и outbox-события в одной DB transaction. ### 4.4. Какие существующие классы участвуют Прямо существующие storage-классы в route-processing-service отсутствуют. ### 4.5. Нужно ли вводить новую модель Да: ```text services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportEntity.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxEntity.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportRepository.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportOutboxRepository.kt ``` ### 4.6. Слой `adapter / storage` ### 4.7. `RoutePassportEntity` Поля entity должны 1-в-1 соответствовать таблице `route_passports`. Важные правила: 1. `routeId` — `@Id`. 2. `version` — `@Version`, если нужна optimistic locking. 3. `createdAt` и `updatedAt` заполнять в коде, без магии БД. 4. `polarisation` и `rawPayload` можно хранить как JSON string с `columnDefinition = "jsonb"`. 5. `geometryWkt` хранить как `TEXT`. ### 4.8. `RoutePassportOutboxEntity` Поля entity должны соответствовать таблице `route_passport_outbox`. Важные правила: 1. `id` — generated UUID в коде. 2. `routeId` можно хранить как UUID без JPA relation, чтобы не усложнять entity graph. 3. `payload` хранить как JSON string с `columnDefinition = "jsonb"`. 4. `headers` хранить как JSON string или nullable JSON string. 5. `status` — enum string. Не нужно делать сложные bidirectional JPA relations. Они не нужны для этого flow. ### 4.9. Repository methods `RoutePassportRepository`: ```kotlin interface RoutePassportRepository : JpaRepository ``` `RoutePassportOutboxRepository`: ```kotlin interface RoutePassportOutboxRepository : JpaRepository { // native query with FOR UPDATE SKIP LOCKED for claim operation } ``` Для outbox лучше сделать не один “find pending”, а сервисный метод `claimDueBatch`, который в transaction: 1. выбирает due rows; 2. ставит `locked_until`; 3. возвращает claimed rows. ### 4.10. Acceptance criteria 1. Entity валидируются Hibernate against Flyway schema. 2. Нет лишних JPA relations, которые усложняют загрузку. 3. Есть repository method для claim pending outbox rows. --- ## Шаг 5. Добавить `RouteGeometryConverter` и заменить ручную GeoJSON → WKT конвертацию ### 5.1. Шаг реализации Вынести конвертацию geometry из `RoutePassportJsonParserService` в отдельный сервис и использовать библиотечную конвертацию. ### 5.2. Вход шага Текущий код в `RoutePassportJsonParserService`: ```kotlin private fun RawGeoJsonGeometryDto.toWkt(): String private fun tools.jackson.databind.JsonNode.toWktCoordinates(depth: Int): String ``` ### 5.3. Выход шага Ручная рекурсивная сборка WKT удалена. Parser делегирует конвертацию в `RouteGeometryConverter`. ### 5.4. Какие существующие классы участвуют | Класс | Как участвует | |---|---| | `RoutePassportJsonParserService` | Перестает собирать WKT вручную. | | `RawGeoJsonGeometryDto` | Вход converter’а. | | `RoutePassportDto` | Получает готовый `geometry: String`. | ### 5.5. Нужно ли вводить новую модель Да: ```text RouteGeometryConverter ``` Отдельные DTO не нужны. ### 5.6. Слой `algorithm` ### 5.7. Предлагаемая ответственность converter ```text RouteGeometryConverter ├── принимает RawGeoJsonGeometryDto ├── проверяет supported geometry type: Polygon / MultiPolygon ├── преобразует raw geometry в GeoJSON string/object ├── читает geometry через JTS-compatible reader ├── пишет WKT через WKT writer └── выбрасывает RoutePassportContractException при invalid geometry ``` ### 5.8. Что считать ошибкой контракта | Ситуация | Исключение | Retry? | |---|---|---| | `geometry.type` не `Polygon` и не `MultiPolygon` | `RoutePassportContractException` | Нет | | coordinates отсутствуют или не массив | `RoutePassportContractException` | Нет | | geometry не парсится библиотекой | `RoutePassportContractException` | Нет | | geometry пустая | `RoutePassportContractException` | Нет | ### 5.9. Почему отдельный converter — не лишняя прослойка Это не абстракция “на будущее”. Сейчас parser смешивает две разные ответственности: 1. разбор route passport fields; 2. геометрический алгоритм GeoJSON → WKT. После замены ручной конвертации на библиотеку converter будет изолировать геометрическую логику и упростит тестирование. ### 5.10. Acceptance criteria 1. В `RoutePassportJsonParserService` нет ручного `toWktCoordinates`. 2. `Polygon` и `MultiPolygon` продолжают поддерживаться. 3. Ошибки geometry классифицируются как non-retryable contract errors. 4. Есть unit tests на converter. --- ## Шаг 6. Добавить exception taxonomy ### 6.1. Шаг реализации Ввести явные exception-классы для классификации ошибок. ### 6.2. Вход шага Сейчас parser использует `require(...)` и `error(...)`, что приводит к generic `IllegalArgumentException`/`IllegalStateException`. ### 6.3. Выход шага Kafka error handler может различать retryable и non-retryable ошибки. ### 6.4. Какие существующие классы участвуют | Класс | Как участвует | |---|---| | `RoutePassportJsonParserService` | Бросает contract exceptions вместо generic error. | | `RouteGeometryConverter` | Бросает contract exceptions при invalid geometry. | | `KafkaConsumerConfig` | Регистрирует non-retryable exceptions. | | `RoutePassportMessageListener` | Не проглатывает ошибки. | ### 6.5. Нужно ли вводить новую модель Да: ```kotlin class RoutePassportContractException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) class RoutePassportProcessingException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) ``` `RoutePassportRetryableException` можно не вводить, если retryable определяются по типам инфраструктурных исключений. ### 6.6. Слой `domain / algorithm / adapter` ### 6.7. Error classification | Категория | Примеры | Поведение | |---|---|---| | `SUCCESS` | parse ok, route saved, outbox created, DB commit ok | Kafka offset ack | | `BUSINESS/CONTRACT` | invalid JSON shape, missing required field, invalid angle, unsupported geometry | no retry или минимальный retry, потом DLQ/DLT | | `RETRYABLE` | transient DB error, temporary DB connection issue | bounded retry, потом DLQ/DLT | | `FATAL` | unexpected bug, incompatible serialization, unknown runtime failure | bounded retry, потом DLQ/DLT + alert | ### 6.8. Acceptance criteria 1. Parser/converter не используют `error(...)` для contract validation. 2. Bad input предсказуемо классифицируется как non-retryable. 3. Infrastructure failures остаются retryable. --- ## Шаг 7. Добавить `RoutePassportIngestionService` ### 7.1. Шаг реализации Ввести application service, который в одной DB transaction: 1. сохраняет/обновляет `route_passports`; 2. создает две outbox-записи; 3. не публикует Kafka напрямую. ### 7.2. Вход шага `ParsedRoutePassportMessage` + исходная Kafka metadata: ```text message value record key topic partition offset ``` ### 7.3. Выход шага DB transaction committed: ```text route_passports row route_passport_outbox row: ModeStatusChangedEvent route_passport_outbox row: RouteGeoRefEvent ``` ### 7.4. Какие существующие классы участвуют | Класс | Как участвует | |---|---| | `RoutePassportMessageListener` | Будет вызывать ingestion вместо publisher. | | `RoutePassportJsonParserService` | Возвращает `ParsedRoutePassportMessage`. | | `RoutePassportKafkaPublisherService` | Убирается из listener path. | | `KafkaTopicsProperties` | Нужен для создания outbox rows. | | `KafkaMessage` | Payload сохраняется в outbox. | | `PcpKafkaEvent` | Типы outbox events. | ### 7.5. Нужно ли вводить новую модель Да: ```text RoutePassportIngestionService RoutePassportSourceRecordMetadata ``` `RoutePassportSourceRecordMetadata` может быть простой internal data class: ```kotlin data class RoutePassportSourceRecordMetadata( val topic: String, val partition: Int, val offset: Long, val key: String?, ) ``` ### 7.6. Слой `orchestration / application` ### 7.7. Транзакционная логика Псевдологика: ```text @Transactional fun ingest(parsedMessage, sourceRecord, rawPayload) { val route = parsedMessage.routePassportDto upsert route_passports by route.routeId create outbox event if absent: routeId + ModeStatusChangedEvent create outbox event if absent: routeId + RouteGeoRefEvent } ``` ### 7.8. Upsert route passport Варианты: #### Вариант A — простой JPA save ```text if route exists: update fields else: insert fields ``` Плюс: понятно и просто. Минус: два запроса. #### Вариант B — native upsert ```sql INSERT ... ON CONFLICT(route_id) DO UPDATE ... ``` Плюс: атомарнее. Минус: больше SQL, сложнее тестировать. Рекомендация: начать с Варианта A. Он достаточно надежен при текущей нагрузке и проще для команды. ### 7.9. Outbox payload generation Сохранять в outbox уже готовый JSON payload, а не DTO. Причина: outbox row должен быть самодостаточным. Если код DTO изменится после сохранения outbox row, старое pending event все равно должно быть опубликовано как было сформировано в момент ingest. Payload должен повторять текущий формат `RoutePassportKafkaPublisherService.buildPayload(...)`: ```kotlin KafkaMessage( type = eventType, data = parsedMessage.routePassportDto, traceId = parsedMessage.metadata.traceId, correlationId = parsedMessage.metadata.correlationId, group = "pcp", ).apply { source = "route-processing" } ``` ### 7.10. Kafka message key Пункт смены key пропущен, поэтому сохраняем текущую семантику: ```text message_key = parsedMessage.metadata.traceId ``` Это не лучший business key, но это осознанно вне scope. ### 7.11. Acceptance criteria 1. Listener больше не публикует Kafka напрямую. 2. При успешной обработке input message в БД появляется route row и две outbox rows. 3. Повторная обработка того же routeId не создает дублей outbox rows. 4. `route_status` сохраняется как `PROCESSED`, как и сейчас. --- ## Шаг 8. Переделать `RoutePassportMessageListener` на controlled commit ### 8.1. Шаг реализации Переписать listener так, чтобы он: 1. принимал `ConsumerRecord`; 2. парсил value; 3. вызывал `RoutePassportIngestionService`; 4. подтверждал offset только после успешного завершения ingest; 5. не вызывал `RoutePassportKafkaPublisherService` напрямую. ### 8.2. Вход шага Текущий listener: ```kotlin fun listen(message: String) ``` ### 8.3. Выход шага Новый listener: ```kotlin fun listen(record: ConsumerRecord, acknowledgment: Acknowledgment) ``` ### 8.4. Какие существующие классы участвуют | Класс | Изменение | |---|---| | `RoutePassportMessageListener` | Основное изменение. | | `RoutePassportJsonParserService` | Остается. | | `RoutePassportKafkaPublisherService` | Убирается из constructor injection listener’а. | | `RoutePassportIngestionService` | Добавляется в constructor injection listener’а. | | `KafkaTopicsProperties` | Остается. | ### 8.5. Нужно ли вводить новую модель Да, если используется `RoutePassportSourceRecordMetadata`. ### 8.6. Слой `adapter / messaging`, `orchestration` ### 8.7. Listener behavior Псевдологика: ```text listen(record, ack): log received topic/partition/offset/payloadSize parsedMessage = parser.parse(record.value()) ingestionService.ingest( parsedMessage = parsedMessage, rawPayload = record.value(), sourceRecord = metadata(record), ) ack.acknowledge() ``` Если parser/ingestion бросили exception: ```text exception bubbles to Spring Kafka DefaultErrorHandler ack is not called handler applies retry/DLQ policy ``` ### 8.8. Acceptance criteria 1. В listener нет `try/catch`, который ломает error handler, либо catch только логирует и rethrow. 2. `ack.acknowledge()` вызывается строго после успешного `ingestionService.ingest(...)`. 3. Offset не подтверждается при parse/DB ошибках. --- ## Шаг 9. Настроить Kafka consumer на manual ack и disable auto commit ### 9.1. Шаг реализации Изменить Kafka consumer config. ### 9.2. Вход шага Текущий config: ```yaml spring: kafka: consumer: enable-auto-commit: true ``` Текущий `KafkaConsumerConfig` читает `enable-auto-commit`, но container factory не задает ack mode и error handler. ### 9.3. Выход шага Consumer не коммитит offset автоматически. Ack происходит вручную после DB commit. ### 9.4. Какие существующие классы участвуют | Класс/файл | Изменение | |---|---| | `config-repo/pcp-route-processing-service.yaml` | `enable-auto-commit: false`, `listener.ack-mode`. | | `KafkaConsumerConfig.kt` | Установить `AckMode.MANUAL_IMMEDIATE`, error handler. | ### 9.5. Нужно ли вводить новую модель Нет. ### 9.6. Слой `adapter / infrastructure` ### 9.7. YAML ```yaml spring: kafka: consumer: enable-auto-commit: false listener: ack-mode: manual_immediate ``` ### 9.8. Kotlin config В `KafkaConsumerConfig.kafkaListenerContainerFactory()` добавить: ```kotlin containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE setCommonErrorHandler(defaultErrorHandler) ``` ### 9.9. Acceptance criteria 1. Offset commit невозможен до явного ack. 2. При exception ack не вызывается. 3. Error handler получает ошибку и применяет retry/DLQ policy. --- ## Шаг 10. Настроить bounded retry + DLQ/DLT для input topic ### 10.1. Шаг реализации Добавить `DefaultErrorHandler` и `DeadLetterPublishingRecoverer` в `KafkaConsumerConfig`. ### 10.2. Вход шага Ошибки из: ```text RoutePassportJsonParserService RouteGeometryConverter RoutePassportIngestionService JPA/database layer ``` ### 10.3. Выход шага Ошибочные input messages не теряются и не создают бесконечный retry. ### 10.4. Какие существующие классы участвуют | Класс | Изменение | |---|---| | `KafkaConsumerConfig` | Добавить beans для error handler/recoverer. | | `KafkaProducerConfig` | KafkaTemplate используется recoverer’ом для DLT publish. | | `RoutePassportContractException` | Non-retryable exception. | | `RoutePassportMessageListener` | Не проглатывает исключения. | ### 10.5. Нужно ли вводить новую модель Да: ```text RoutePassportContractException ``` И properties: ```text RoutePassportDlqProperties / RoutePassportConsumerErrorHandlingProperties ``` Можно обойтись `@Value`, но typed properties лучше для читаемости. ### 10.6. Слой `adapter / infrastructure` ### 10.7. DLQ topic naming Не использовать topic вида: ```text pcp.request.survey-georeference.v1.dlt ``` Он нарушает правило, что major version должен быть в конце. Рекомендуемый DLQ/DLT topic: ```text pcp.request.survey-georeference-failed.v1 ``` или, если команда предпочитает явно `dlq`: ```text pcp.request.survey-georeference-dlq.v1 ``` В плане используем: ```text pcp.request.survey-georeference-dlq.v1 ``` Добавить в config: ```yaml app: kafka: topics: input: pcp.request.survey-georeference.v1 processed-route: pcp.route.in.v1 route-georeference: pcp.route.georeference.v1 input-dlq: pcp.request.survey-georeference-dlq.v1 ``` Соответственно расширить `KafkaTopicsProperties`: ```kotlin val inputDlq: String = "" ``` ### 10.8. Retry policy Рекомендуемый стартовый вариант: ```yaml app: route-passport: consumer: retry: max-attempts: 3 backoff-ms: 1000 max-backoff-ms: 10000 ``` Поведение: | Exception | Retry | Destination | |---|---:|---| | `RoutePassportContractException` | 0 или 1 | DLQ сразу | | JSON parse/mapping exception | 0 или 1 | DLQ сразу | | Invalid geometry | 0 или 1 | DLQ сразу | | DB connection/transient exceptions | bounded retry | DLQ after attempts | | Unexpected runtime exceptions | bounded retry | DLQ after attempts | ### 10.9. DLT payload/headers `DeadLetterPublishingRecoverer` должен сохранять: 1. original payload; 2. original topic; 3. original partition; 4. original offset; 5. exception class; 6. exception message; 7. service name; 8. timestamp. Spring Kafka добавляет часть headers сам. Если стандартных headers недостаточно, добавить custom headers в recoverer. ### 10.10. Observability Логировать: ```text routeId if parsed topic/partition/offset traceId correlationId exception category DLQ topic ``` Не логировать полный payload. Только preview/размер. ### 10.11. Acceptance criteria 1. Bad contract message уходит в DLQ и не блокирует consumer group бесконечно. 2. Retry ограничен по количеству попыток. 3. DLQ topic имеет version suffix `.v1`. 4. В logs понятно, почему сообщение ушло в DLQ. --- ## Шаг 11. Настроить producer reliability ### 11.1. Шаг реализации Добавить надежные настройки Kafka producer. ### 11.2. Вход шага Текущий `KafkaProducerConfig` задает только: ```text bootstrap.servers key.serializer value.serializer ``` Даже если добавить настройки в YAML, текущий custom config их не подхватит автоматически. ### 11.3. Выход шага Kafka producer получает reliability properties явно. ### 11.4. Какие существующие классы участвуют | Класс/файл | Изменение | |---|---| | `KafkaProducerConfig.kt` | Добавить producer properties в map. | | `config-repo/pcp-route-processing-service.yaml` | Добавить `acks`, `retries`, `enable.idempotence`. | ### 11.5. Нужно ли вводить новую модель Нет. ### 11.6. Слой `adapter / infrastructure` ### 11.7. YAML ```yaml spring: kafka: producer: acks: all retries: 10 properties: enable.idempotence: true max.in.flight.requests.per.connection: 5 ``` ### 11.8. Kotlin config Не делаем полный refactor на `KafkaProperties`, потому что это вне scope. Но текущую map нужно расширить: ```kotlin ProducerConfig.ACKS_CONFIG to acks ProducerConfig.RETRIES_CONFIG to retries ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to enableIdempotence ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to maxInFlightRequests ``` ### 11.9. Что это дает | Настройка | Что дает | |---|---| | `acks=all` | Broker подтверждает запись только после записи leader + in-sync replicas. | | `retries` | Producer повторяет отправку при временных ошибках. | | `enable.idempotence=true` | Защита от дублей, возникающих из-за producer retries. | | `max.in.flight.requests.per.connection=5` | Совместимо с idempotent producer. | ### 11.10. Чего это не дает Producer reliability не заменяет outbox. Outbox нужен, чтобы событие не потерялось, если: 1. Kafka недоступна; 2. сервис упал; 3. publish прошел частично; 4. producer не успел отправить после обработки input. ### 11.11. Acceptance criteria 1. Outbox worker использует producer с `acks=all` и idempotence. 2. Ошибка producer publish не теряет событие, а переводит outbox row в retry/failed state. --- ## Шаг 12. Переделать `RoutePassportKafkaPublisherService` под outbox ### 12.1. Шаг реализации Разделить две ответственности: 1. формирование outbox payload — в `RoutePassportIngestionService` или отдельном small helper; 2. физическая отправка Kafka record — в `RoutePassportKafkaPublisherService`. ### 12.2. Вход шага Текущий `RoutePassportKafkaPublisherService.publish(parsedMessage)`. ### 12.3. Выход шага Новый publisher публикует конкретную outbox row: ```text RoutePassportKafkaPublisherService.publish(outboxEvent) ``` И возвращает результат/бросает exception. ### 12.4. Какие существующие классы участвуют | Класс | Изменение | |---|---| | `RoutePassportKafkaPublisherService` | Больше не строит два события из `ParsedRoutePassportMessage`. Публикует одну outbox row. | | `RoutePassportIngestionService` | Создает две outbox rows. | | `RoutePassportOutboxWorker` | Вызывает publisher. | ### 12.5. Нужно ли вводить новую модель Можно использовать `RoutePassportOutboxEntity` как вход publisher’а. Если не хочется давать service слой доступ к JPA entity, можно добавить internal DTO: ```kotlin data class RoutePassportOutboxMessage( val id: UUID, val topic: String, val messageKey: String, val eventType: String, val payload: String, val headers: Map, ) ``` Но для первого варианта допустимо использовать entity, чтобы не плодить лишние классы. ### 12.6. Слой `adapter / messaging` ### 12.7. Publish behavior Важно: publisher должен дождаться результата отправки. Псевдологика: ```text record = ProducerRecord(topic, messageKey, payload) record.headers.add("type", eventType) future = kafkaTemplate.send(record) future.get(timeout) ``` Если publish упал — бросить exception. Не просто логировать. ### 12.8. Acceptance criteria 1. Ошибка Kafka publish видна outbox worker’у как exception. 2. Publisher не знает ничего про два downstream topic. Он публикует одну outbox row. 3. Заголовок `type` сохраняется как сейчас. --- ## Шаг 13. Добавить `RoutePassportOutboxWorker` ### 13.1. Шаг реализации Добавить scheduled worker, который публикует pending outbox events. ### 13.2. Вход шага Rows в `route_passport_outbox` со статусом `PENDING` и `next_attempt_at <= now()`. ### 13.3. Выход шага Rows становятся: ```text PUBLISHED — если Kafka publish успешен PENDING — если ошибка retryable и attempts < max_attempts FAILED — если attempts >= max_attempts ``` ### 13.4. Какие существующие классы участвуют | Класс | Как участвует | |---|---| | `RoutePassportKafkaPublisherService` | Физически отправляет Kafka record. | | `RoutePassportOutboxRepository` | Читает/обновляет outbox rows. | | `KafkaTopicsProperties` | Не нужен worker’у напрямую, topic уже записан в outbox. | ### 13.5. Нужно ли вводить новую модель Да: ```text RoutePassportOutboxWorker RoutePassportOutboxService RoutePassportOutboxProperties ``` ### 13.6. Слой `orchestration / adapter` ### 13.7. Properties ```yaml app: route-passport: outbox: enabled: true batch-size: 50 fixed-delay-ms: 1000 lock-timeout-ms: 60000 publish-timeout-ms: 10000 max-attempts: 10 initial-backoff-ms: 1000 max-backoff-ms: 60000 ``` ### 13.8. Worker algorithm ```text on schedule: claimedEvents = outboxService.claimDueBatch(batchSize) for event in claimedEvents: try: publisher.publish(event) outboxService.markPublished(event.id) catch exception: outboxService.markRetryOrFailed(event.id, exception) ``` ### 13.9. Claim algorithm В отдельной transaction: ```text 1. select due rows with FOR UPDATE SKIP LOCKED 2. set locked_until = now + lockTimeout 3. update updated_at 4. return rows ``` Native query example: ```sql SELECT * FROM route_passport_outbox WHERE status = 'PENDING' AND next_attempt_at <= now() AND (locked_until IS NULL OR locked_until < now()) ORDER BY created_at LIMIT :limit FOR UPDATE SKIP LOCKED; ``` После select обновить `locked_until` у выбранных rows. ### 13.10. Backoff formula Простая exponential backoff с cap: ```text nextDelay = min(initialBackoffMs * 2^(attempts - 1), maxBackoffMs) ``` Для первого варианта можно без jitter. Если будут синхронные retry spikes — добавить jitter позже. ### 13.11. At-least-once behavior Outbox worker дает at-least-once delivery. Возможен дубль в сценарии: ```text 1. Kafka publish succeeded. 2. Service crashed before marking row as PUBLISHED. 3. locked_until expired. 4. Worker retries same event. ``` Это нормально для outbox. Downstream consumers обязаны быть идемпотентными. Текущий downstream status: | Consumer | Идемпотентность | |---|---| | `pcp-request-service` / `RouteMatchingService` | Частично есть: `request_route_matches` unique `(request_id, route_id)` защищает от дублей matching. | | `pcp-mission-planing-service` | Требует отдельной проверки: повторный `ModeStatusChangedEvent` может повторно обновить режим и породить побочные события. | ### 13.12. Acceptance criteria 1. Pending outbox rows публикуются в Kafka. 2. Ошибки publish не теряют rows. 3. После max attempts row становится `FAILED`. 4. Несколько instances сервиса не публикуют один row одновременно. --- ## Шаг 14. Настроить storage и outbox observability ### 14.1. Шаг реализации Добавить логи и базовые метрики для ingestion/outbox. ### 14.2. Вход шага Новые сервисы: ```text RoutePassportIngestionService RoutePassportOutboxWorker RoutePassportKafkaPublisherService ``` ### 14.3. Выход шага По logs/metrics можно понять: 1. сколько маршрутов принято; 2. сколько outbox events pending; 3. сколько published; 4. сколько failed; 5. какие ошибки уходят в DLQ. ### 14.4. Какие существующие классы участвуют | Класс | Изменение | |---|---| | `RoutePassportMessageListener` | Логи input topic/partition/offset. | | `RoutePassportIngestionService` | Логи routeId, status, created/updated, outbox inserted/skipped. | | `RoutePassportOutboxWorker` | Логи batch size, publish success/failure. | | `RoutePassportKafkaPublisherService` | Логи topic/partition/offset после publish. | ### 14.5. Нужно ли вводить новую модель Нет. ### 14.6. Слой `observability` ### 14.7. Рекомендуемые log fields ```text routeId routeNameFull traceId correlationId sourceTopic sourcePartition sourceOffset outboxEventId eventType targetTopic attempts status ``` ### 14.8. Метрики Если в проекте уже есть Micrometer через Actuator, добавить counters/gauges: ```text route_passport_ingest_success_total route_passport_ingest_failure_total route_passport_outbox_published_total route_passport_outbox_failed_total route_passport_outbox_retry_total route_passport_outbox_pending_count route_passport_outbox_failed_count ``` Gauge `pending_count` и `failed_count` можно добавить позже, если не хочется усложнять первый MR. Counters на publish/retry/fail — минимально полезны. ### 14.9. Acceptance criteria 1. В логах можно отследить путь routeId от input до outbox publish. 2. Failed outbox rows не теряются незаметно. 3. DLQ события видны в логах с причиной. --- ## Шаг 15. Обновить конфигурацию topic properties ### 15.1. Шаг реализации Расширить `KafkaTopicsProperties` новым DLQ topic. ### 15.2. Вход шага Текущий класс: ```kotlin @ConfigurationProperties(prefix = "app.kafka.topics") data class KafkaTopicsProperties( val input: String = "", val processedRoute: String = "", val routeGeoreference: String = "", ) ``` ### 15.3. Выход шага Добавить: ```kotlin val inputDlq: String = "" ``` ### 15.4. Какие существующие классы участвуют | Класс | Изменение | |---|---| | `KafkaTopicsProperties` | Добавить `inputDlq`. | | `KafkaConsumerConfig` | Использовать `inputDlq` в `DeadLetterPublishingRecoverer`. | | `config-repo/pcp-route-processing-service.yaml` | Добавить topic name. | ### 15.5. Нужно ли вводить новую модель Нет. ### 15.6. Слой `adapter / config` ### 15.7. Acceptance criteria 1. Сервис не стартует без DLQ topic name. 2. DLQ topic задается централизованно через config server. --- ## Шаг 16. Минимальные тесты ### 16.1. Шаг реализации Добавить/обновить тесты только под измененную логику. ### 16.2. Вход шага Текущие тесты: ```text RoutePassportJsonParserServiceTest.kt RoutePassportKafkaPublisherServiceTest.kt RoutePassportMessageListenerTest.kt RouteProcessingTestControllerTest.kt ``` ### 16.3. Выход шага Тесты покрывают: 1. parser; 2. geometry converter; 3. ingestion service; 4. outbox worker; 5. listener ack/error behavior; 6. error classification. ### 16.4. Какие существующие классы участвуют | Тест | Что изменить | |---|---| | `RoutePassportJsonParserServiceTest` | Убрать ожидания ручной WKT конвертации, использовать `RouteGeometryConverter`. | | `RoutePassportKafkaPublisherServiceTest` | Проверять publish одной outbox row и exception on failure. | | `RoutePassportMessageListenerTest` | Проверять, что listener вызывает ingestion и ack после успеха; не ack при exception. | ### 16.5. Нужно ли вводить новую модель Только test fixtures/builders, если они упрощают тесты. ### 16.6. Слой `tests` ### 16.7. Unit tests #### `RouteGeometryConverterTest` Проверки: | Case | Ожидание | |---|---| | valid Polygon | возвращается WKT `POLYGON ...` | | valid MultiPolygon | возвращается WKT `MULTIPOLYGON ...` | | unsupported geometry type | `RoutePassportContractException` | | invalid coordinates | `RoutePassportContractException` | | empty geometry | `RoutePassportContractException` | #### `RoutePassportJsonParserServiceTest` Проверки: | Case | Ожидание | |---|---| | valid envelope | возвращается `ParsedRoutePassportMessage` | | missing routeId | routeId генерируется из `id`, как сейчас | | rollAngle list | min/max вычисляются корректно | | visirAngle list | min/max вычисляются корректно | | invalid interval | contract exception | | routeStatus | остается default `PROCESSED` | #### `RoutePassportIngestionServiceTest` Проверки: | Case | Ожидание | |---|---| | first route ingest | created route + 2 outbox rows | | duplicate same routeId | no duplicate outbox rows | | updated same routeId | route row updated, outbox unique preserved | | raw payload | saved to `raw_payload` | | source metadata | topic/partition/offset saved | #### `RoutePassportOutboxWorkerTest` Проверки: | Case | Ожидание | |---|---| | pending event publish success | status `PUBLISHED`, `published_at` set | | publish failure attempts < max | status `PENDING`, attempts incremented, `next_attempt_at` moved | | publish failure attempts >= max | status `FAILED`, `last_error` set | | locked row | not claimed until lock expires | #### `RoutePassportMessageListenerTest` Проверки: | Case | Ожидание | |---|---| | success | parser + ingestion called, ack called | | parser exception | ack not called, exception rethrown | | ingestion exception | ack not called, exception rethrown | ### 16.8. Integration tests Минимально желательно добавить JPA test для repositories и migration validation. Варианты: 1. если Testcontainers уже используется в проекте — использовать PostgreSQL Testcontainer; 2. если нет — на первом этапе ограничиться `@DataJpaTest` + embedded database нельзя, потому что используются JSONB и PostgreSQL-specific SQL; 3. pragmatic вариант — unit tests + локальный manual check через real PostgreSQL в dev environment. Рекомендация: добавить Testcontainers PostgreSQL, если это приемлемо для проекта. Если нет — явно прописать manual verification checklist. ### 16.9. Acceptance criteria 1. Измененная логика покрыта unit tests. 2. Outbox retry/fail behavior проверен. 3. Listener ack behavior проверен. 4. Parser сохраняет прежнюю бизнес-семантику routeStatus = PROCESSED. --- ## Шаг 17. Обновить runtime configuration ### 17.1. Шаг реализации Свести все новые настройки в config server file. ### 17.2. Вход шага ```text config-repo/pcp-route-processing-service.yaml ``` ### 17.3. Выход шага Один конфиг содержит: 1. datasource; 2. flyway; 3. kafka consumer manual ack settings; 4. producer reliability; 5. DLQ topic; 6. outbox properties. ### 17.4. Какие существующие классы участвуют | Класс | Как участвует | |---|---| | `KafkaTopicsProperties` | topic settings. | | `RoutePassportOutboxProperties` | outbox worker settings. | | `KafkaConsumerConfig` | retry/ack settings. | | `KafkaProducerConfig` | producer reliability settings. | ### 17.5. Нужно ли вводить новую модель Да, желательно: ```kotlin @ConfigurationProperties(prefix = "app.route-passport.outbox") data class RoutePassportOutboxProperties(...) ``` Для retry тоже можно: ```kotlin @ConfigurationProperties(prefix = "app.route-passport.consumer.retry") data class RoutePassportConsumerRetryProperties(...) ``` ### 17.6. Слой `adapter / config` ### 17.7. Итоговый YAML fragment ```yaml spring: datasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_route_processing username: postgres password: password jpa: hibernate: ddl-auto: validate properties: hibernate: format_sql: true flyway: enabled: true baseline-on-migrate: true locations: classpath:db/migration kafka: bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} consumer: group-id: pcp-route-processing-service auto-offset-reset: earliest key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer enable-auto-commit: false producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer acks: all retries: 10 properties: enable.idempotence: true max.in.flight.requests.per.connection: 5 listener: missing-topics-fatal: false ack-mode: manual_immediate app: kafka: topics: input: pcp.request.survey-georeference.v1 input-dlq: pcp.request.survey-georeference-dlq.v1 processed-route: pcp.route.in.v1 route-georeference: pcp.route.georeference.v1 route-passport: consumer: retry: max-attempts: 3 backoff-ms: 1000 max-backoff-ms: 10000 outbox: enabled: true batch-size: 50 fixed-delay-ms: 1000 lock-timeout-ms: 60000 publish-timeout-ms: 10000 max-attempts: 10 initial-backoff-ms: 1000 max-backoff-ms: 60000 ``` ### 17.8. Acceptance criteria 1. Все новые параметры находятся в config-repo. 2. Defaults безопасны для dev/local. 3. Production может менять retry/outbox параметры без пересборки сервиса. --- # 7. Итоговый список новых файлов ## 7.1. Migrations ```text services/pcp-route-processing-service/src/main/resources/db/migration/V1__create_route_passports.sql services/pcp-route-processing-service/src/main/resources/db/migration/V2__create_route_passport_outbox.sql ``` ## 7.2. Entities ```text services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportEntity.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxEntity.kt ``` ## 7.3. Repositories ```text services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportRepository.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportOutboxRepository.kt ``` ## 7.4. Services ```text services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RouteGeometryConverter.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxWorker.kt ``` ## 7.5. Exceptions ```text services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/exception/RoutePassportContractException.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/exception/RoutePassportProcessingException.kt ``` `RoutePassportProcessingException` optional. Добавлять только если реально нужен для отделения unexpected processing failures от generic exceptions. ## 7.6. Config properties ```text services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/RoutePassportOutboxProperties.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/RoutePassportConsumerRetryProperties.kt ``` --- # 8. Итоговый список изменяемых файлов ```text services/pcp-route-processing-service/build.gradle.kts config-repo/pcp-route-processing-service.yaml services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfig.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaProducerConfig.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaTopicsProperties.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListener.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserService.kt services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherService.kt services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListenerTest.kt services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserServiceTest.kt services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherServiceTest.kt ``` --- # 9. Порядок реализации по MR/commit-ам ## MR 1. DB foundation Состав: 1. build.gradle: JPA/Flyway/PostgreSQL/JTS dependencies; 2. datasource/flyway config; 3. migrations `route_passports`, `route_passport_outbox`; 4. entities/repositories; 5. basic repository tests или compile validation. Риск: низкий/средний. Код обработки Kafka еще можно не переключать. --- ## MR 2. Parser cleanup and geometry converter Состав: 1. `RouteGeometryConverter`; 2. убрать ручной WKT converter из parser; 3. добавить `RoutePassportContractException`; 4. обновить parser tests; 5. добавить converter tests. Риск: средний, потому что меняется geometry output. Нужно проверить WKT formatting compatibility с `pcp-request-service`. --- ## MR 3. Ingestion service + outbox writing Состав: 1. `RoutePassportIngestionService`; 2. создание route row; 3. создание двух outbox rows; 4. idempotency by `route_id + event_type`; 5. tests на duplicate processing. Риск: средний. Kafka listener еще можно временно не переключать, если сделать service isolated. --- ## MR 4. Listener controlled commit + DLQ Состав: 1. listener принимает `ConsumerRecord` + `Acknowledgment`; 2. listener вызывает ingestion; 3. manual ack после успешного ingest; 4. `enable-auto-commit=false`; 5. `DefaultErrorHandler` + `DeadLetterPublishingRecoverer`; 6. DLQ topic config; 7. tests на ack/not ack. Риск: высокий, потому что меняется delivery semantics входного Kafka topic. --- ## MR 5. Outbox publisher worker Состав: 1. `RoutePassportOutboxService`; 2. `RoutePassportOutboxWorker`; 3. publish one outbox row; 4. mark published/retry/failed; 5. producer reliability config; 6. tests на success/retry/failed. Риск: высокий, потому что это основной механизм downstream publication. --- ## MR 6. Observability and hardening Состав: 1. structured logs; 2. metrics/counters; 3. failed outbox visibility; 4. manual replay notes/runbook draft; 5. финальный cleanup. Риск: низкий. --- # 10. Поведение после внедрения ## 10.1. Happy path ```text 1. Kafka input message received. 2. Parser reads envelope and creates RoutePassportDto. 3. Geometry converter converts GeoJSON geometry to WKT. 4. Ingestion service stores/updates route_passports. 5. Ingestion service creates outbox events: - ModeStatusChangedEvent - RouteGeoRefEvent 6. DB transaction commits. 7. Listener acknowledges Kafka offset. 8. Outbox worker publishes events. 9. Outbox rows become PUBLISHED. ``` ## 10.2. Bad input ```text 1. Kafka input message received. 2. Parser/converter detects contract error. 3. Listener throws RoutePassportContractException. 4. Error handler sends original input to DLQ. 5. Offset is committed after recoverer succeeds. 6. No route_passports row is created. 7. No outbox rows are created. ``` ## 10.3. DB down during ingest ```text 1. Kafka input message received. 2. Parser succeeds. 3. Ingestion fails due to DB error. 4. Listener throws exception. 5. Error handler retries with bounded backoff. 6. If DB recovers, ingest succeeds and offset is acked. 7. If DB does not recover after max attempts, message goes to DLQ. ``` ## 10.4. Kafka output down during outbox publish ```text 1. Input message already safely stored in DB. 2. Outbox worker tries to publish. 3. Kafka publish fails. 4. Outbox row remains retryable. 5. next_attempt_at is moved by backoff. 6. Worker retries later. 7. After max attempts row becomes FAILED. ``` --- # 11. Production readiness checklist ## 11.1. Correctness - [ ] `route_passports` stores all normalized route passport fields. - [ ] `route_status` remains `PROCESSED` intentionally. - [ ] `route_id` is primary idempotency key. - [ ] Duplicate input message does not create duplicate outbox events. - [ ] Geometry conversion supports Polygon and MultiPolygon. - [ ] Invalid geometry goes to DLQ, not infinite retry. ## 11.2. Reliability - [ ] `enable-auto-commit=false`. - [ ] Manual ack happens only after DB commit. - [ ] Listener does not publish Kafka output directly. - [ ] Outbox worker retries failed publishes. - [ ] Outbox worker marks permanently failed events. - [ ] Kafka producer uses `acks=all`. - [ ] Kafka producer idempotence is enabled. - [ ] DLQ topic configured. - [ ] Retry is bounded. ## 11.3. Observability - [ ] Logs include `routeId`, `traceId`, `correlationId`, source topic/partition/offset. - [ ] Outbox failed events are visible. - [ ] DLQ sends are logged with exception category. - [ ] Metrics exist for ingest success/failure and outbox publish/fail/retry. ## 11.4. Operational - [ ] DB `pcp_route_processing` exists in target environment. - [ ] Flyway migrations run successfully. - [ ] DLQ topic exists or auto-created by Kafka infra policy. - [ ] Output topics exist: - [ ] `pcp.route.in.v1` - [ ] `pcp.route.georeference.v1` - [ ] Runbook exists for failed outbox rows. - [ ] Runbook exists for DLQ replay. --- # 12. Runbook drafts ## 12.1. Проверить зависшие outbox events ```sql SELECT status, COUNT(*) FROM route_passport_outbox GROUP BY status; ``` ```sql SELECT id, route_id, event_type, topic, attempts, next_attempt_at, locked_until, last_error FROM route_passport_outbox WHERE status IN ('PENDING', 'FAILED') ORDER BY updated_at DESC LIMIT 100; ``` ## 12.2. Переотправить FAILED outbox event вручную Только после проверки причины ошибки. ```sql UPDATE route_passport_outbox SET status = 'PENDING', attempts = 0, next_attempt_at = now(), locked_until = NULL, last_error = NULL, updated_at = now() WHERE id = '' AND status = 'FAILED'; ``` ## 12.3. Найти route passport по routeId ```sql SELECT * FROM route_passports WHERE route_id = ''; ``` ## 12.4. Проверить события по routeId ```sql SELECT id, event_type, topic, status, attempts, published_at, last_error FROM route_passport_outbox WHERE route_id = '' ORDER BY created_at; ``` --- # 13. Риски, которые останутся после выполнения плана | Риск | Почему остается | Что делать позже | |---|---|---| | Kafka key остается `traceId` | Пункт смены key исключен из scope. | Отдельно решить: `routeId` или `correlationId` как business key. | | Нет fallback trace/correlation | Пункт исключен из scope. | Добавить генерацию fallback и явно логировать missing metadata. | | `routeStatus` всегда `PROCESSED` | Осознанное решение оставить как есть. | Когда появится контракт статусов, читать статус из input и валидировать enum. | | Test REST controller остается в main-коде | Пункт исключен из scope. | Удалить или закрыть через `@Profile("local", "dev")`. | | README/fixtures могут быть устаревшими | Пункт исключен из scope. | Обновить документацию контракта route passport. | | Downstream idempotency не полностью проверена | План меняет producer delivery на at-least-once. | Проверить `pcp-mission-planing-service` на повторный `ModeStatusChangedEvent`. | | Нет Kafka transactions | Выбран outbox вместо Kafka transactions. | Это нормально, если downstream consumers идемпотентны. | --- # 14. Критерии готовности всей задачи Задачу можно считать выполненной, если: 1. `pcp-route-processing-service` имеет собственную БД `pcp_route_processing`. 2. Принятый route passport сохраняется в `route_passports`. 3. Downstream-события создаются в `route_passport_outbox`, а не публикуются напрямую из listener. 4. Kafka offset input message подтверждается только после успешного DB commit. 5. Outbox worker публикует pending events в: - `pcp.route.in.v1`; - `pcp.route.georeference.v1`. 6. Ошибки publish не теряют события: outbox row остается retryable или становится `FAILED` после max attempts. 7. Bad input уходит в DLQ/DLT topic. 8. Retry bounded, бесконечного retry нет. 9. GeoJSON → WKT конвертация выполняется библиотечно. 10. `routeStatus` остается `PROCESSED`, и это явно зафиксировано в коде/тестах как текущая бизнес-семантика. 11. Минимальные тесты покрывают parser, converter, ingestion, outbox worker, listener ack/error behavior. --- # 15. Короткая итоговая формулировка для задачи в трекере ```md Доработать `pcp-route-processing-service` route-passport flow до production-ready обработки: - добавить service-owned PostgreSQL DB `pcp_route_processing`; - сохранять принятые route passports в `route_passports`; - реализовать DB outbox `route_passport_outbox` для downstream Kafka events; - убрать прямую публикацию Kafka events из input listener; - подтверждать input Kafka offset только после DB commit; - добавить bounded retry + DLQ для input topic; - добавить outbox worker для публикации `ModeStatusChangedEvent` и `RouteGeoRefEvent`; - настроить Kafka producer reliability (`acks=all`, retries, idempotence); - заменить ручной GeoJSON → WKT converter на библиотечный; - оставить текущую семантику `routeStatus = PROCESSED`; - не добавлять BPM/Camunda, flow остается Kafka-only. ```