Files
dc-observatio/docs/standards/DATETIME_UTC.md
T
Дмитрий Соловьев 405f02aa86 pcp: слой толерантности времени — читать naive(=UTC) и Z во всём флоте
PcpTimeModule (libs/pcp-types-lib): толерантные Jackson-десериализаторы
LocalDateTime/OffsetDateTime/Instant — любой потребитель читает оба формата
на проводе и нормализует к UTC. Только чтение, формат записи не меняется.

Подключение фронтом по флоту:
- Spring Boot auto-configuration + AutoConfiguration.imports — Boot-маппер
  всех 12 сервисов на pcp-types-lib (без правок в сервисах);
- Jackson SPI (META-INF/services) — ручные мапперы с findAndRegisterModules();
- точечно: dynamic-plan/JacksonConfig (ручной ObjectMapper);
- tgu (WebFlux, не зависит от либы) — локальное зеркало модуля в
  CamundaJacksonConfig (Jackson-2 путь).

Зачем: микросервисы деплоятся независимо; с толерантностью продюсера можно
флипать LocalDateTime -> OffsetDateTime (Z) в любом порядке без синхронного
деплоя — потребитель не падает на парсинге офсета.

Тесты: PcpTimeModuleTest (оба формата -> один UTC-момент + авто-подхват SPI).
Доки: DATETIME_UTC.md (раздел 2bis + найденные баги формата), CHECKLIST.md.
2026-06-02 22:35:50 +03:00

15 KiB
Raw Blame History

DATETIME_UTC.md — единообразие времени (UTC) в группе сервисов pcp

Назначение

Этот документ фиксирует соглашение по работе со временем во всей группе сервисов pcp и состояние сервисов на момент аудита. На него ссылаются KDoc у DTO и комментарии в коде. Цель — убрать неоднозначность парсинга/форматирования и неоднородность формата на проводе, которая уже приводила к сдвигу времени на офсет пояса (для МСК — +3 ч).

1. Соглашение (canonical)

  1. Всё время — UTC. На любом API времена представляют момент в UTC. В БД — тоже UTC.
  2. Формат на проводе — ISO-8601 с явной зоной Z (предпочтительно). Где сервис исторически отдаёт naive LocalDateTime (ISO без зоны, напр. "2026-05-29T01:00:00"), это допустимо только при железном правиле «naive = UTC»; такой эндпоинт — кандидат на миграцию к Z.
  3. Backend:
    • «сейчас» — только UTC: Instant.now() или LocalDateTime.now(ZoneOffset.UTC); не LocalDateTime.now();
    • конвертация Instant/OffsetDateTimeLocalDateTime для naive-полей — только через UTC (LocalDateTime.ofInstant(instant, ZoneOffset.UTC), withOffsetSameInstant(ZoneOffset.UTC)), никогда через ZoneId.systemDefault() / atZone(systemDefault());
    • Jackson во всех сервисах настроен с JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS), поэтому LocalDateTime уходит как ISO без зоны, а OffsetDateTime/Instantс Z/офсетом.
  4. Frontend (pcp-tgu-ops-ui):
    • парсинг строк бэка — только через src/utils/utcTime.ts (parseUtc); никаких «голых» new Date(string) / Date.parse(string);
    • отображение — только UTC (formatUtcDateTime/Date/Time либо toLocale* с timeZone: "UTC");
    • <input type="datetime-local"> — значение через toUtcInputValue, чтение через parseUtc, отправка на offset-бэк — через toUtcIso.
    • Инвариант закреплён тестом-стражем src/utils/utcTime.guard.test.ts (падает на запрещённых паттернах).
  5. Никакой неявной локализации под пояс машины ни на фронте, ни в логах/ответах бэка.

2. Эталоны переиспользования

  • Frontend: services/pcp-tgu-ops-ui/src/utils/utcTime.ts — единая точка парсинга/форматирования.
  • Backend (чтение): libs/pcp-types-lib utils/json/PcpTimeModule — слой толерантности (бэковый аналог parseUtc): любой потребитель читает и naive(=UTC), и Z/офсет (см. раздел ниже).
  • Backend (запись): pcp-request-service отдаёт OffsetDateTime (строки с Z) и конвертирует вход через OffsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() — корректный образец.

2bis. Слой толерантности времени (включатель безопасного флипа)

PcpTimeModule (libs/pcp-types-lib/.../utils/json/PcpTimeModule.kt) — Jackson-модуль с толерантными десериализаторами для LocalDateTime, OffsetDateTime, Instant. Любой потребитель с ним читает время в обоих форматах на проводе и нормализует к UTC:

Тип поля naive …T01:00:00 с зоной …T01:00:00Z / +03:00
LocalDateTime как есть (= UTC) приводится к UTC, зона отбрасывается
OffsetDateTime atOffset(UTC) withOffsetSameInstant(UTC)
Instant трактуется как момент UTC штатно

Модуль трогает только чтение — формат записи определяется типом поля и не меняется, поэтому подключение слоя само по себе не сдвигает ни один контракт.

Зачем. Микросервисы деплоятся независимо. Раньше перевод продюсера LocalDateTimeOffsetDateTime (на проводе появляется Z) ломал ещё не обновлённого потребителя — штатный LocalDateTimeDeserializer кидает исключение на офсете. С толерантностью во всём флоте продюсера можно флипать в любом порядке, без синхронного деплоя. Это снимает требование «продюсер + все потребители одним коммитом».

Подключение (три механизма, чтобы покрыть все мапперы):

  • Spring Boot auto-configuration PcpTimeJacksonAutoConfiguration (@Bean Module) + META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports — Boot-managed ObjectMapper каждого из 12 сервисов, зависящих от pcp-types-lib (правок в сервисах не требуется).
  • Jackson SPI META-INF/services/com.fasterxml.jackson.databind.Module — ручные мапперы с findAndRegisterModules() (напр. pcp-request-service).
  • Точечная регистрация в мапперах, что обходят оба: pcp-dynamic-plan-service/JacksonConfig.

Особый случай — pcp-tgu-service. Он не зависит от pcp-types-lib и работает на WebFlux (подключение либы притянуло бы spring-boot-starter-web/MVC и переключило тип приложения на сервлетный). Поэтому в tgu лежит локальное зеркало config/PcpTimeModule.kt (поведение обязано совпадать с библиотечным), зарегистрированное в Jackson-2 бине CamundaJacksonConfig — покрывает Camunda/внутренний парсинг. Остаётся под-задача: REST-codec WebFlux на Jackson 3 (tools.jackson.*, иной API) ещё не толерантен — нужен Jackson-3 вариант модуля + регистрация в кодеках.

Контракт слоя закреплён PcpTimeModuleTest (pcp-types-lib): оба формата → один и тот же UTC-момент, плюс проверка авто-подхвата через SPI.

3. Состояние сервисов (срез аудита)

Формат на проводе определяется Jackson-сериализацией: LocalDateTime → ISO без зоны (naive, = UTC), OffsetDateTime/Instant → ISO с Z. «Потребители» — кто читает время этого сервиса.

Сервис Преобладает на границе API Naive? Вердикт
pcp-request-service OffsetDateTime (Z) нет эталон: явная зона на проводе
pcp-tgu-service REST PlanResponseOffsetDateTime (Z); Kafka/прочие DTO — LocalDateTime частично PlanResponse мигрирован на Z; баги конвертаций исправлены; Kafka-DTO остаются naive (=UTC)
pcp-ui-service LocalDateTime (агрегатор/CZML, summaries); план ТГУ — OffsetDateTime (Z) частично TguPlanResponse мигрирован на Z вслед за tgu; прочие naive DTO — задокументировать
pcp-complex-mission-service LocalDateTime (+ часть Instant) смешано проверить смешение; задокументировать
pcp-dynamic-plan-service LocalDateTime (+ offset на входе клиента) да задокументировать «=UTC»
pcp-ballistics-service LocalDateTime да задокументировать «=UTC»
pcp-mission-planing-service LocalDateTime (вход — OffsetDateTime) да задокументировать «=UTC»
slots-service LocalDateTime да задокументировать «=UTC»; проверить rs.getTimestamp().toLocalDateTime()
pcp-coverage-scheme-service LocalDateTime да задокументировать «=UTC»
pcp-route-processing-service нет времени на границе API
pcp-satellite-catalog-service нет времени на границе API
pcp-stations-service нет времени на границе API
tle-monitoring-service нет времени на границе API

4. Известные расхождения и под-задачи (миграция к Z — не «большим взрывом»)

Систематический класс: LocalDateTime.now() без UTC-зоны (~80 мест: entity-дефолты createdAt/updatedAt, воркеры, use case'ы). На не-UTC JVM пишет локальное «сейчас» в UTC-поля. Низкий риск для audit-колонок, но формально неверно. Рекомендованные под-задачи по сервисам:

  • pcp-tgu-service — заменить оставшиеся LocalDateTime.now() (воркеры выдачи/решений, PlanQueryService cutoff, entity-дефолты) на LocalDateTime.now(ZoneOffset.UTC); тест на cutoff истории.
  • slots-service — проверить SlotRepositoryImpl.rs.getTimestamp("tn").toLocalDateTime() (зависит от зоны драйвера); зафиксировать чтение в UTC.
  • pcp-mission-planing-service / pcp-dynamic-plan-serviceOffsetDateTime.toLocalDateTime() теряет офсет; убедиться, что вход всегда UTC, иначе конвертировать через withOffsetSameInstant(UTC).
  • Все сервисы с naive LocalDateTime на границе — добавить сериализационный тест, фиксирующий формат на проводе (есть Z / нет зоны), затем по согласованию мигрировать DTO к OffsetDateTime/Instant (UTC). Фронтовый parseUtc уже совместим с обоими форматами, поэтому миграция безопасна при пер-сервисном раскате.

Контрактные тесты формата (зафиксировано)

Оба формата на проводе закреплены сериализационными тестами (mapper зеркалит конфиг Spring Boot — JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS)):

  • PlanResponseContractTest (pcp-tgu-service) — OffsetDateTime с Z (после миграции, см. ниже).
  • RequestResponseDtoContractTest (pcp-request-service) — OffsetDateTime с Z (эталон).
  • TguPlanningDtoContractTest (pcp-ui-service) — план ТГУ OffsetDateTime с Z + Instant с Z.

Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme, mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании пер-сервисный страж копируется по образцу RequestResponseDtoContractTest.

Миграция к Z — выполнено

  • План ТГУ (tgu → ui-service → фронт). PlanResponse (pcp-tgu-service) и зависящий от него TguPlanResponse (pcp-ui-service) переведены с naive LocalDateTime на OffsetDateTime в UTC → на проводе Z. Домен/БД (PlannedPlanEntity/CalculatedPlan) остались naive LocalDateTime (=UTC); конвертация только на границе (atOffset(ZoneOffset.UTC)). Потребитель один (ui-service), обновлён синхронно; фронт parseUtc совместим. Контракт закреплён тестами выше.

  • Слой толерантности (PcpTimeModule) — раскатан по флоту. 12 сервисов на pcp-types-lib получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — локальным зеркалом (Jackson-2 путь). Это включатель: дальнейший флип продюсеров на Z больше не требует синхронного деплоя.

После толерантности продюсеров можно флипать в любом порядке. Кандидаты: остальные naive-DTO ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme.

Найденные баги формата (теперь безопасно чинить — потребители толерантны)

  • KafkaMessage.time (общий Kafka-конверт, pcp-types-lib). time = LocalDateTime.now() (локальное!)
    • сериализуется LocalDateTimeTimeZoneSerializer через ZoneId.systemDefault() → конверт несёт МСК-стенные часы с +03:00, а потребитель (формат …SSS[xxx] в LocalDateTime) отбрасывает зону → сдвиг. Под-задача: now() → UTC и сериализатор конверта → UTC/Z. Раньше блокировалось риском поломки потребителей; со слоем толерантности — безопасно.
  • pcp-tgu-service REST-codec на Jackson 3 — ещё не толерантен (см. 2bis); под-задача на Jackson-3 вариант модуля.

5. Правило для нового кода

  • Frontend: только utils/utcTime (страж utcTime.guard.test.ts не даст просочиться нарушителю).
  • Backend: UTC-now; на новых API предпочтителен явный Z (OffsetDateTime/Instant).
  • Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).