Системный (событийный) сценарий: PIN не владеет справочными данными (страны, регионы, города, валюты,
языки) — их источник истины внешний, talent.microservices.handbook. Синк потребляет upsert-события
источника и поддерживает в контексте handbookлокальную реплику пяти справочников, а каждое
применённое изменение re-publish-ит собственным событием PIN в compacted-топики pin.handbook.*.v1, чтобы
downstream-сервисы PIN (chessboard и будущие потребители) зависели только от PIN, а не подписывались на
Talent напрямую. Ключевые особенности: у событий источника нет per-record версии — порядок применения
задаётся временем публикации (CreatedAt envelope) и порядком партиции; синк идемпотентен к повторам;
источник не эмитит delete-события (delete-gap) — физических удалений в PIN нет вообще, устаревшие записи
выявляет reconciliation после полной пересинхронизации, а решение об архиве принимает администратор
(UC-HND-001). Связан с контекстом handbook.
Топики источника talent.microservices.handbook доступны (страны, города+регион, валюты, языки); пятый
поток источника — контактная информация — вне scope v1 и не потребляется.
Consumer group pin-handbook-talent-sync работает; целевые реплики и чекпоинты синка (HandbookSyncState)
существуют или создаются первым событием записи.
Downstream-сервисы PIN подписаны на pin.handbook.*.v1, а не на топики Talent.
Consumer group pin-handbook-talent-sync (talent-handbook-sync.md): upsert-событие страны / города (+ вложенный регион) / валюты / языка; key = id записи (per-record порядок в партиции)
Дом алгоритма/маппинга — консюмер talent-handbook-sync.md; ниже —
поток на уровне use-case (единая рамка для всех четырёх потоков; поток «город» дополнительно derive-ит регион).
Приём и контекст. Консюмер получает upsert-событие записи; восстанавливает метаданные: MessageId
(для дедупликации), envelope CreatedAt (время публикации источником — источник монотонности, т.к.
per-record версии в payload нет), ContractVersion (ожидается 2; при несовпадении — warn-and-proceed +
метрика), id записи (key).
Идемпотентность по сообщению. По ключу handbook-sync:{тип}:{MessageId} проверяется, обработано ли
сообщение: дубль → пропуск (skipped-duplicate, ack), состояние не меняется.
Проверка входа. Обязательные поля snapshot-а присутствуют и нормализованы (ISO-коды к нужному
регистру, имена — truncate + warn при переполнении); envelope CreatedAt задан. Существование записи не
проверяется — операция upsert.
Монотонный guard (упорядочение по CreatedAt). Если запись уже есть и envelope
CreatedAt <= LastEventAt — событие устаревшее/повтор пересинхронизации: skipped-stale (A1), при этом
обновляется только отметка живости чекпоинта (LastEventId/LastEventAt) — основа reconciliation, — а
реплика-поля не трогаются. Так поздний повтор не откатывает более свежий snapshot.
Доменные guard-ы и решения.
Зависимость country→city (только поток города): страна по ссылке из city-события должна существовать;
нет → retriable-ожидание (A2) — страна придёт своим событием, city-событие не теряется.
ISO-уникальность: ISO-коды не заняты другой неархивной записью (INV-HND-2); конфликт → отказ и
DLQ (A4).
Гео-согласованность (регион/город): страна региона берётся из страны вмещающего city-события; смена
страны региона не поддерживается — нарушение → DLQ (A5).
Auto-restore: если запись была заархивирована в PIN, но пришла из источника — архив снимается
(IsArchived = false) + warn-метрика: источник главнее локального архива (A3).
Upsert реплики (snapshot-семантика). Синк-владеемые поля перезаписываются целиком по snapshot-у
источника; LastEventAt = envelope CreatedAt. Для потока города дополнительно derive-ится регион из
вложенной модели (создание/обновление записи региона под собственным монотонным guard-ом — независимо от
города).
Re-publish события PIN. В том же согласованном изменении публикуется {тип}-upserted в compacted-топик
pin.handbook.{countries|regions|cities|currencies|languages}.v1 (key = id, полный snapshot реплики +
IsArchived + LastEventAt). Для города регион re-publish-ится только при фактическом изменении записи
региона (дедупликация шторма одинаковых регионов от разных городов).
Согласованность и ack. Реплика, чекпоинт HandbookSyncState (Status = Synced), отметка
идемпотентности и исходящие события фиксируются атомарно; offset подтверждается только после фиксации.
Downstream-сервисы PIN получают pin.handbook.* и обновляют свои локальные реплики (секунды eventual).
A1. Устаревшее / повторное событие (упорядочение по CreatedAt)
Событие с envelope CreatedAt <= LastEventAt существующей записи (out-of-order доставка или повтор
пересинхронизации) → реплика-поля не меняются, skipped-stale + метрика; обновляется отметка живости
чекпоинта (LastEventId/LastEventAt). Ack. Так порядок применения не зависит от порядка доставки, а
определяется временем публикации источником.
A2. City-событие обогнало country
Страна по ссылке города ещё не создана (country- и city-топики взаимно не упорядочены) → retriable-отказ
HANDBOOK_SYNC_COUNTRY_NOT_FOUND; ретрай с backoff (несколько попыток). После прихода country-события
повтор успешен — город появится с запозданием (окно eventual). Исчерпание попыток → DLQ + алерт (разбор:
пришло ли country-событие / нужна ли пересинхронизация).
A3. Auto-restore архивной записи
Запись была заархивирована администратором в PIN (UC-HND-001),
но пришла upsert-событием из источника (не была удалена в источнике / заведена заново) → синк снимает архив
(IsArchived = false) + warn-метрика (источник главнее локального архива); реплика-поля перезаписываются;
re-publish события. Если скрытие значения всё же нужно — вопрос решается правкой данных в Talent, а не
повторным архивом в PIN.
A4. ISO-конфликт
ISO-код записи занят другой неархивной записью того же справочника (INV-HND-2) → non-retriable-отказ
HANDBOOK_SYNC_ISO_CONFLICT → DLQ + алерт + HandbookSyncState.Status = Conflict. Автопочинки в PIN нет:
разрешение (какая запись легитимна) — правкой данных в источнике, затем replay из DLQ.
A5. Гео-рассогласование региона
Страна вмещающего city-события не совпадает со страной существующей записи региона (попытка «переноса»
региона между странами, не поддерживается, INV-HND-3) → non-retriable HANDBOOK_SYNC_GEO_MISMATCH → DLQ +
алерт. Разрешение — правка данных в источнике + replay.
A6. Полная пересинхронизация и delete-gap (reconciliation)
Источник не эмитит delete-события — запись, удалённая в Talent, продолжает жить в реплике PIN
(delete-gap). Периодически/по требованию проводится полная пересинхронизация (повторный проход всех
топиков источника): upsert + monotonic-guard превращают повторы в no-op — реплика сходится к текущему
snapshot-у источника без искажений.
По итогам ресинка формируется reconciliation-отчёт: строки реплики, не пришедшие в окно ресинка,
помечаются кандидатами в архив. Автоматического архива по отсутствию нет — синк только выявляет расхождение;
вывод записи из оборота — ручное решение администратора (UC-HND-001,
talent-initial-import.md). Так delete-gap источника компенсируется
без риска потерять валидную запись.
Целевая реплика записи отражает последний snapshot источника (синк-владеемые поля перезаписаны;
LastEventAt = envelope CreatedAt; при auto-restore архив снят); чекпоинт HandbookSyncState = Synced
(либо Conflict/DeadLettered при A4/A5/исчерпании ретраев).
Для потока города при наличии региона — запись региона derive-нута и согласована.
Опубликованы соответствующие pin.handbook.*-события; локальные реплики downstream (chessboard)
согласуются асинхронно (секунды). Устаревшее/дублирующее событие состояние реплики не изменило (skip),
отметка живости обновлена.
Физических удалений не выполнялось; delete-gap источника компенсируется reconciliation + ручным архивом.
Upsert-событие страны создаёт/обновляет реплику по snapshot-семантике, ставит LastEventAt = envelope CreatedAt, публикует country-upserted (snapshot + IsArchived); чекпоинт Synced
интеграционный тест потока country
AC2
Порядок применения — по envelope CreatedAt: событие с CreatedAt ≤ LastEventAt не откатывает реплику (skipped-stale), обновляет только отметку живости (A1)
тест монотонности (out-of-order доставка)
AC3
Повторная доставка того же MessageId — skipped-duplicate, состояние не меняется, повторное событие не публикуется
тест идемпотентности
AC4
City-событие раньше country → HANDBOOK_SYNC_COUNTRY_NOT_FOUND, retry; после country повтор успешен, город появляется (A2)
тест out-of-order country→city
AC5
Город с регионом derive-ит запись региона (страна региона = страна города); region-upserted публикуется только при фактическом изменении региона
ISO-конфликт → DLQ + Status = Conflict, реплика не изменена (A4); смена страны региона → DLQ GEO_MISMATCH (A5)
негативные тесты guard-ов
AC8
Delete-gap: удалённая в источнике запись живёт в реплике; полная пересинхронизация сходится (повторы no-op), reconciliation-отчёт помечает кандидатов в архив; автоархива нет (A6)
тест ресинка + reconciliation
AC9
Downstream PIN зависят только от pin.handbook.* — на топики Talent не подписаны; событие несёт полный snapshot + IsArchived