Реплика Talent Handbook: правила синка + Entity HandbookSyncState
Дом правил репликации справочников из talent.microservices.handbook в контекст handbook PIN
(страны, регионы, города, валюты, языки — ADR-0063)
- дом полей служебной сущности
HandbookSyncState(чекпоинты синка). Поля целевых реплик НЕ переописываются — см. country.md, region.md, city.md, currency.md, language.md; здесь — реестр потребляемых топиков, таблицы маппинга «поле события Talent → поле реплики PIN» и правила R1–R9.
Назначение
Определяет: какие события Talent Handbook потребляются (и какие — нет), как поля событий источника
маппятся в реплики PIN, кто владеет какими полями, как обеспечиваются идемпотентность, монотонность
применения (по времени публикации источника — envelope CreatedAt + порядок партиции, а не по per-record
версии, которой у событий справочников нет), порядок country→city и устойчивость к out-of-order, что происходит с удалениями источника
(delete-gap) и как выполняется начальная загрузка / полная пересинхронизация. Consumer, реализующий эти
правила, — talent-handbook-sync.md; bootstrap и
reconciliation — talent-initial-import.md.
Классификация (HandbookSyncState)
| Поле | Значение |
|---|---|
| Контекст | handbook |
| Kind | Entity (служебная, anemic) |
| Source of truth | handbook (мета о синке); справочные данные — talent.microservices.handbook |
| Таблица | handbook.talent_sync_states |
| Tenant isolation | не tenanted — платформенная служебная таблица |
Реестр потребляемых топиков
Контракты внешние — фиксируются как upstream source of truth (schema registry Talent, ContractVersion = 2).
Consumer group — pin-handbook-talent-sync, DLQ — pin.handbook.talent-sync-dlq.v1.
| Topic (Talent) | Payload | Реплика PIN | Re-publish PIN |
|---|---|---|---|
handbooks.after-country-information-update | полный snapshot страны | CountryEntity (handbook.countries) | country-upserted → pin.handbook.countries.v1 |
handbooks.after-city-information-update | полный snapshot города + вложенные CountryEventModel и RegionEventModel | CityEntity (handbook.cities) + derive RegionEntity (handbook.regions) | city-upserted → pin.handbook.cities.v1; при фактическом изменении региона — region-upserted → pin.handbook.regions.v1 |
handbooks.after-currency-information-update | полный snapshot валюты | CurrencyEntity (handbook.currencies) | currency-upserted → pin.handbook.currencies.v1 |
handbooks.after-language-information-update | полный snapshot языка | LanguageEntity (handbook.languages) | language-upserted → pin.handbook.languages.v1 |
handbooks.after-contact-information-update | ContactInfoType | НЕ потребляется — вне scope v1 (ADR-0063 п.3): потребителей ContactInfoType в PIN нет; подключение при появлении потребности — additive (новый поток + реплика + свой pin.handbook.*-топик) | — |
Отдельного region-события нет — регионы derive-ятся из вложенной модели city-событий; delete-событий нет — удалённая запись просто перестаёт упоминаться (обрабатывается правилами R5/R7 и reconciliation).
Маппинг: country-событие → CountryEntity
Событие handbooks.after-country-information-update несёт полные поля страны.
| Поле события Talent | Тип | Поле реплики PIN | Правило |
|---|---|---|---|
Id | short | Id | Id PIN = id источника: прямая адресация без lookup-таблиц (ADR-0063 п.2). |
ShortName | string? | ShortName | Перезапись при каждом применённом upsert (поле владеет синк). |
FullName | string | FullName | Перезапись; display-конвенция Name = FullName (country.md). Длиннее 64 символов — truncate + warn, событие не теряется. |
IsoCode2 | string | IsoCode2 | Нормализация в upper; уникальность среди неархивных стран (INV-HND-2) — конфликт → R6. |
IsoCode3 | string | IsoCode3 | Нормализация в upper; INV-HND-2 — конфликт → R6. |
DefaultCurrency? {CurrencyId, IsoCode, Sign, Name} | модель? | DefaultCurrencyId | Берётся только CurrencyId; display-поля вложенной модели не сохраняются — атрибуты валюты приходят её собственным событием. Логическая ссылка без FK: нерезолвимость до прихода currency-события допустима. |
DefaultLanguage? {LanguageId, IsoCode2, IsoCode3, Name} | модель? | DefaultLanguageId | Аналогично: только LanguageId, без FK. |
CreatedAt (envelope события, метаданные) | DateTimeOffset | LastEventAt | Монотонный guard R2 (INV-HND-4): применяется только при CreatedAt > LastEventAt. У событий источника нет per-record Version — источник монотонности — время публикации (envelope CreatedAt) + порядок партиции Kafka (key = Id); денормализуется в HandbookSyncState.LastEventAt. |
| — | — | IsArchived | Синк не пишет, кроме auto-restore R5 (IsArchived = false при повторном приходе архивной записи). |
| — | — | CreatedAt / UpdatedAt | Штампы применения. |
Маппинг: city-событие → CityEntity + derive RegionEntity
Событие handbooks.after-city-information-update — единственный источник и городов, и регионов.
Город
| Поле события Talent | Тип | Поле реплики PIN | Правило |
|---|---|---|---|
Id | long | Id | Id PIN = id источника. |
Name | string | Name | Перезапись; длиннее 256 символов — truncate + warn. |
Country (CountryEventModel {CountryId short, Name, IsoCode2, IsoCode3}) | модель | CountryId | Берётся только CountryId. Вложенная модель страну не создаёт и не обновляет (в ней нет полных полей) — страна обязана уже существовать своим событием; отсутствует → retriable HANDBOOK_SYNC_COUNTRY_NOT_FOUND (R4). |
Region? (RegionEventModel {RegionId long, Name}) | модель? | RegionId | RegionId = Region.RegionId; null — город вне региона (напр., города федерального значения). RegionEventModel не несёт CountryId — страну региона задаёт CountryId вмещающего city-события (ADR-0063 п.4). Согласованность цепочки — INV-HND-3 (city.md): смена страны у существующего региона → HANDBOOK_SYNC_GEO_MISMATCH → DLQ. |
CreatedAt (envelope, метаданные) | DateTimeOffset | LastEventAt | Монотонный guard R2 (INV-HND-4): CreatedAt > LastEventAt. Per-record Version у события нет — монотонность по envelope CreatedAt + порядок партиции (key = Id). |
| — | — | IsArchived | Только auto-restore R5. |
Регион (derive из вложенной RegionEventModel)
Поле RegionEventModel / метаданные | Тип | Поле реплики PIN | Правило |
|---|---|---|---|
RegionId | long | Id | Id PIN = id CountryRegion источника. |
Country.CountryId вмещающего city-события (не поле RegionEventModel) | short | CountryId | Страна региона derive-ится из CountryId city-события — RegionEventModel собственного CountryId не несёт (ADR-0063 п.4). Задаётся при создании; смена страны у существующего региона → нарушение INV-HND-3 → HANDBOOK_SYNC_GEO_MISMATCH → DLQ (region.md). |
Name | string | Name | Перезапись; длиннее 256 символов — truncate + warn. |
CreatedAt city-события (envelope, метаданные) | DateTimeOffset | LastEventAt | У источника нет per-record версии региона — монотонность по времени публикации: применяется только при CreatedAt > LastEventAt (INV-HND-4, R2). |
| — | — | IsArchived | Только auto-restore R5. |
Guard-ы региона и города независимы: stale-город не блокирует применение более свежего региона, и
наоборот. region-upserted re-publish-ится только если запись региона фактически изменилась (создание,
смена Name, restore) — переименование региона размножено по city-событиям всех его городов, и
дедупликация фактических изменений защищает потребителей от шторма одинаковых событий.
Связи
Логические ссылки (без FK) между репликами и служебный чекпоинт HandbookSyncState — по одной строке на
(AggregateType, ExternalId) каждой из пяти реплик:
Маппинг: currency-событие → CurrencyEntity
| Поле события Talent | Тип | Поле реплики PIN | Правило |
|---|---|---|---|
Id | short | Id | Id PIN = id источника. |
Name | string | Name | Перезапись; длиннее 64 символов — truncate + warn. |
IsoCode | string | IsoCode | Нормализация в upper (ISO-4217); уникальность среди неархивных валют (INV-HND-2) — конфликт → R6. |
Sign | string? | Sign | Перезапись. |
CreatedAt (envelope, метаданные) | DateTimeOffset | LastEventAt | Монотонный guard R2 (INV-HND-4): CreatedAt > LastEventAt. Per-record Version нет — монотонность по envelope CreatedAt + порядок партиции (key = Id). |
| — | — | IsArchived | Только auto-restore R5. |
Маппинг: language-событие → LanguageEntity
| Поле события Talent | Тип | Поле реплики PIN | Правило |
|---|---|---|---|
Id | short | Id | Id PIN = id источника. |
Name | string | Name | Перезапись; длиннее 64 символов — truncate + warn. |
IsoCode2 | string | IsoCode2 | Нормализация в lower (ISO 639-1); INV-HND-2 — конфликт → R6. |
IsoCode3 | string | IsoCode3 | Нормализация в lower (ISO 639-2/3); INV-HND-2 — конфликт → R6. |
FlagIsoCode2 | string? | FlagIsoCode2 | Нормализация в upper — код страны флага (display-атрибут, логическая ссылка без FK). |
CreatedAt (envelope, метаданные) | DateTimeOffset | LastEventAt | Монотонный guard R2 (INV-HND-4): CreatedAt > LastEventAt. Per-record Version нет — монотонность по envelope CreatedAt + порядок партиции (key = Id). |
| — | — | IsArchived | Только auto-restore R5. |
Правила синка (R1–R9)
| # | Правило | Содержание |
|---|---|---|
| R1 Идемпотентность | Повторная доставка события не меняет состояние | Дедупликация по идентификатору события (MessageId): ключ handbook-sync:{eventType}:{MessageId}, eventType ∈ {country, city, currency, language}; дубль → skip + ack. Бизнес-идемпотентность — R2 |
| R2 Монотонность применения | Событие применяется только если его envelope CreatedAt > LastEventAt реплики (INV-HND-4). У событий источника нет per-record Version — источник монотонности — время публикации (envelope CreatedAt) и порядок партиции Kafka (key = Id → апдейты одной записи не переупорядочиваются); guard — «не откатывать реплику назад по CreatedAt источника». Одинаково для стран, городов, валют, языков и региона (derive) | Stale-событие (CreatedAt <= LastEventAt) → skip реплики + метрика handbook_sync_stale_events_total; при этом HandbookSyncState.LastEventId/LastEventAt обновляются — отметка «запись жива в источнике», основа reconciliation-отчёта R9 |
| R3 Snapshot-семантика | Upsert-событие несёт полный snapshot записи; применение перезаписывает все синк-владеемые поля (не диффы) | Поле IsArchived синк не трогает (кроме R5): архив — локальное решение PIN (ADR-0063 п.5) |
| R4 Порядок country→city | Кросс-справочный порядок источником не гарантирован. City-событие (и derive региона в нём) при отсутствии страны → retriable HANDBOOK_SYNC_COUNTRY_NOT_FOUND (retry с backoff → DLQ после исчерпания); страна появится своим событием, city-событие не теряется | DefaultCurrencyId/DefaultLanguageId страны — логические ссылки без FK: нерезолвимость до прихода события валюты/языка допустима и retry не требует |
| R5 Auto-restore | Повторный приход заархивированной в PIN записи из источника → IsArchived = false + warn-метрика handbook_sync_auto_restores_total + re-publish | Источник главнее локального архива: раз запись re-эмитится — она жива; расхождение сигнализируется, но не блокирует синк |
| R6 ISO-конфликт | Нарушение INV-HND-2 (ISO-код занят другой неархивной записью того же справочника) → HANDBOOK_SYNC_ISO_CONFLICT, non-retriable → DLQ + алерт; HandbookSyncState.Status = Conflict | Разрешение только ручное: данные правятся в Talent Handbook → replay события из DLQ (автопочинки на стороне PIN нет) |
| R7 Удаления | Источник delete-событий не шлёт — физических удалений справочных записей в PIN нет вообще. Вывод из оборота — только IsArchived через admin archive/restore и reconciliation-процедуру (кандидаты подтверждает администратор) | ADR-0063 п.5; исторические ссылки (<X>Id в старых документах/событиях) не ломаются — resolve включает архивные (INV-HND-5) |
| R8 Атомарность | Мутация реплики + HandbookSyncState + отметка идемпотентности + re-publish pin.handbook.* — одно согласованное изменение; offset подтверждается только после фиксации | Для city-события: регион + город + оба чекпоинта + оба исходящих события — в одном изменении |
| R9 Bootstrap / полный ресинк | Начальная загрузка и полная пересинхронизация — re-эмиссия всех строк источником (POST sync-handbooks Talent) и штатное применение consumer-ом; reconciliation-отчёт по не пришедшим в окно строкам | talent-initial-import.md; downstream-потребители (chessboard) бутстрапятся чтением compacted pin.handbook.* с offset 0 — без участия синка |
Поля HandbookSyncState
Чекпоинт «одна строка на запись справочника»: диагностика отставания, отметка живости для reconciliation, DLQ-расследования.
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор записи. |
AggregateType | HandbookAggregateType | Да | varchar(32): Country / Region / City / Currency / Language; часть unique | consumer | Тип справочной записи. |
ExternalId | long | Да | unique ux_handbook_sync_states_type_external (aggregate_type, external_id) | consumer | Id записи в источнике (= id реплики PIN); short-id стран/валют/языков хранятся в long без потерь. |
LastEventId | Guid | Да | — | метаданные события | MessageId последнего события по записи — включая stale-skip (R2). |
LastEventAt | DateTimeOffset | Да | index ix_handbook_sync_states_last_event_at | метаданные события | Envelope CreatedAt последнего применённого события по записи — источник монотонности (R2, INV-HND-4): событие применяется только при CreatedAt > LastEventAt. Обновляется и при stale-skip как отметка живости: reconciliation считает кандидатами в архив строки с LastEventAt старше окна ресинка. Единый механизм для всех типов записей — отдельного поля версии нет (у источника per-record Version отсутствует). |
Status | HandbookSyncRecordStatus | Да | varchar(32): Synced / Conflict / DeadLettered | consumer | Conflict — ISO/GEO-конфликт данных (R6, INV-HND-3); DeadLettered — событие ушло в DLQ после исчерпания retry (R4). |
LastError | string? | Нет | varchar(1024) | consumer | Текст последней ошибки (без PII — PII в справочниках нет by design). |
CreatedAt / UpdatedAt | DateTimeOffset | Да | — | consumer | Штампы. |
Одна строка на (AggregateType, ExternalId) — unique-индекс; Status = Conflict/DeadLettered
требует ручного действия (правка данных в источнике → replay из DLQ) с возвратом в Synced. stateDiagram
тривиален (Synced ↔ Conflict/DeadLettered), отдельно не приводится.
Входной и исходящий контракты
Входной контракт (что синк ожидает от Talent). Key события = id записи → строгий порядок per-record в
партиции топика; полный snapshot в каждом событии (R3); per-record Version в payload нет — источник
монотонности — envelope CreatedAt (время публикации) + порядок партиции Kafka (key = Id не даёт
переупорядочивания апдейтов одной записи), guard R2 «не откатывать реплику назад по CreatedAt»; статический
ContractVersion = 2 (несовпадение — warn-and-proceed + метрика handbook_sync_contract_mismatch_total).
Кросс-справочный порядок (country→city, currency/language
→ country) не гарантируется — компенсируется R4 и логическими ссылками без FK. Delete-событий нет (R7).
Точные контракты — schema registry talent.microservices.handbook (upstream source of truth).
Имена топиков handbooks.after-* — зафиксированное assumption, подтверждается интеграционным контрактом
при реализации.
Исходящий контракт (что downstream может предполагать). Синк не «тихий»: каждое применённое изменение
(upsert синка, auto-restore, admin archive/restore, пересинхронизация) re-publish-ится собственным событием
PIN {country|region|city|currency|language}-upserted в compacted-топик
pin.handbook.{countries|regions|cities|currencies|languages}.v1: key = Id записи (порядок per-record),
ContractVersion = 1, payload = полный snapshot реплики, включая IsArchived и LastEventAt
(envelope CreatedAt последнего применённого события источника — единый для всех типов записей). Доставка at-least-once после фиксации изменения (R8); compacted-хранение
позволяет новому потребителю бутстрапиться чтением с offset 0 в любой момент. Так как страна применяется
раньше своих регионов/городов (R4), потребитель, зависящий от страны, получает pin.handbook.countries.v1
не позже связанных regions.v1/cities.v1. Downstream-сервисы PIN на топики Talent не подписываются
(ADR-0063 п.1). Контракты событий: ../events/country-upserted.md,
../events/region-upserted.md,
../events/city-upserted.md,
../events/currency-upserted.md,
../events/language-upserted.md.
Наблюдаемость, безопасность
| Аспект | Значение |
|---|---|
| Метрики | handbook_sync_lag_seconds (задержка применения: момент применения − envelope CreatedAt, по AggregateType), handbook_sync_stale_events_total, handbook_sync_dlq_total, handbook_sync_contract_mismatch_total, handbook_sync_auto_restores_total |
| Логи | по событию: MessageId, AggregateType, ExternalId, envelope CreatedAt, действие (created/updated/skipped-stale/auto-restored/conflict/dead-lettered); PII в справочных данных нет |
| Трейсинг | корреляция из метаданных события пробрасывается в исходящие pin.handbook.* |
| Алерты | DLQ > 0; Conflict > 0; лаг применения > 15 мин при ненулевом потоке |
| Runbook | replay DLQ: исправить данные в Talent Handbook → повторная подача события; полная пересинхронизация — R9 (talent-initial-import.md) |
| Безопасность | consumer работает под system-actor (не пользовательский контекст); доступ к топикам Talent — m2m ACL Kafka; справочники не tenanted и не содержат PII |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (5): Entity: CityEntity, Entity: CountryEntity, Entity: CurrencyEntity, Entity: LanguageEntity, Entity: RegionEntity
- API (3): GET /api/v1/admin/handbook/sync-state — Состояние синхронизации справочников с Talent, Handbook API — реестр операций областей public / internal / admin, POST /api/v1/admin/handbook/{type}/{id}/{archive,restore} — Архив и восстановление справочной записи
- Use Cases (3): UC-HND-001. Администратор поддерживает справочник (archive/restore), UC-HND-002. Система синхронизирует реплику справочника из Talent, US-реестр контекста handbook (US-HND)
- События (5): Event Contract: city-upserted (CityUpsertedEvent), Event Contract: country-upserted (CountryUpsertedEvent), Event Contract: currency-upserted (CurrencyUpsertedEvent), Event Contract: language-upserted (LanguageUpsertedEvent), Event Contract: region-upserted (RegionUpsertedEvent)
- Консюмеры (1): Consumer: Talent Handbook Sync (pin-handbook-talent-sync)
- Фоновые задачи (1): Background Job: talent-initial-import (bootstrap и reconciliation справочников из Talent Handbook)
- index.md (1): Handbook Service
- registries (1): Реестр сущностей PIN
Связанные документы
- Целевые дома полей и инвариантов: country.md (INV-HND-1/2/4/5), region.md и city.md (INV-HND-3), currency.md, language.md; snapshot-модели — snapshots.md.
- Consumer детально: ../consumers/talent-handbook-sync.md.
- Bootstrap / reconciliation: ../background/talent-initial-import.md.
- Admin archive/restore: ../api/admin/archive-restore-entry.md; состояние синка: ../api/admin/get-sync-state.md.
- ADR-0063; образец паттерна — identity/domain/talent-replica.md.