ADR-0050: DKC адаптер + федеративная таксономия (cycle 4)

Status: accepted Date: 2026-05-02 Deciders: agent-claude

Контекст

До цикла 4 Tracium поддерживал трёх поставщиков (ETM, IEK, Systeme Electric), все работающие в режиме полного каталога (full-pull). Ingestion pipeline проходил через IngestOrchestrator.RunOnceSessionManagerEnumerator для каждого поставщика.

Цикл 4 добавляет DKC (ДКС, крупный российский дистрибьютор промышленного оборудования) с тремя новыми требованиями, которые раньше не встречались:

  1. Инкрементальная синхронизация через номер ревизии. DKC API предоставляет эндпоинт GET /v1/revisions/materials?since={revision}, возвращающий только изменения с заданной ревизии. Полный каталог — 1 млн+ позиций; full-pull при каждом тике неприемлем по времени и нагрузке на API.

  2. Многовалютность. DKC публикует цены одновременно в RUB и KZT. Таблица offer_observations хранила одну цену без информации о валюте. Схема требовала расширения.

  3. Федеративная таксономия ETIM-7. DKC связывает каждую позицию с классом стандарта ETIM-7 (European Technical Information Model). Tracium не имел инфраструктуры для хранения внешних таксономических стандартов. В дальнейшем потребуются ECLASS-12, GTIN, UNSPSC — хранить их нужно единообразно без изменения схемы при добавлении нового стандарта.

Дополнительный контекст:

  • Механизм аутентификации DKC (master_key_token_exchange) отличается от ETM/IEK/Systeme: статический мастер-ключ обменивается на временный access-token с TTL 24 часа.
  • Цикл 4 первым вводит механизм атрибуции внешних идентификаторов (ETIM class ID на canonical product) с защитой ручной правки: автоматическая запись блокируется, если запись помечена источником manual.
  • Инициатива бидирекционного курсора (tracium_bidirectional_cursor.md): схема хранения позиции курсора должна поддерживать не только входящую синхронизацию от поставщиков, но и будущий исходящий API для B2B-клиентов (GET /api/v1/products/changes?since=<cursor>).

Решение

1. Инкрементальный курсор через таблицу incremental_sync_cursor

Новая таблица incremental_sync_cursor хранит позицию курсора для произвольного субъекта (поставщик или клиент). Ключевые решения:

  • subject_kind ∈ {supplier, client} — единственная таблица для входящей и исходящей синхронизации. Выходящий API (будущий цикл) использует subject_kind=client.
  • cursor_kind ∈ {revision_number, iso_date, page_offset, opaque} — полиморфный тип. DKC использует revision_number; Systeme (будущий retrofit) — iso_date; ETM (вырожденный full-pull случай) — opaque.
  • cursor_value JSONB — нетипизированное хранилище для конкретного значения.
  • Upsert-семантика: тик записывает новый курсор только при успешном завершении, атомарно с записью наблюдений.

2. PipelineRouter — маршрутизация по capabilities

PipelineRouter читает матрицу CapabilitiesRegistry (список возможностей коннектора) и диспатчит тик поставщика в одну из двух реализаций:

  • FullCatalogPipeline — ETM / IEK / Systeme: адаптер поверх существующего IngestOrchestrator.RunOnce.
  • CursorPipelineImpl — DKC: cursor read → revision poll → seed-or-incremental → per-material pull → cursor advance.

Это backward-совместимое изменение: ETM/IEK/Systeme не затрагиваются.

3. Федеративная таксономия — параллельные таблицы по стандарту

Таксономический словарь DKC (ETIM-7) хранится в пяти новых таблицах:

taxonomy_identity_standards   — реестр стандартов (ETIM-7, ECLASS-12, GTIN, ...)
taxonomy_classes              — классы стандарта (PK: standard_code + external_code)
taxonomy_features             — характеристики (PK: standard_code + external_code)
taxonomy_values               — допустимые значения (PK: standard_code + external_code)
taxonomy_units                — единицы измерения (PK: standard_code + external_code)
taxonomy_class_features       — связи класс↔характеристика
characteristic_mappings       — маппинг (standard_code, external_feature_code) → canonical characteristic_id

Ключ (standard_code, external_code) полностью изолирует словари стандартов — добавление ECLASS-12 не меняет схему. Это не слияние в единый canonical-словарь: мержинг eClass + ETIM на уровне canonical требует 6+ месяцев моделирования; реализуется в отдельном цикле при реальной необходимости.

4. Атрибуция идентификаторов через canonical_external_identities

Таблица canonical_external_identities (canonical_id, standard_code, external_code, source) связывает canonical product с классом внешнего стандарта.

Защита ручной правки: ExternalIdentityWriter.Write проверяет существующую запись перед вставкой. Если текущий source = 'manual', автоматическая запись (источники auto_supplier, auto_llm, auto_inference) блокируется. Метрика canonical_external_identity_write_skipped_manual_total сигнализирует о заблокированных перезаписях.

5. Аутентификация master_key с per-process кэшем

MasterKeyTokenExchanger хранит access-token в памяти процесса (24 часа TTL, не в БД). При получении HTTP 401 от DKC API Session сбрасывает кэш и повторяет обмен ровно один раз. Кэш regenerable — потеря при рестарте безвредна.

6. Фоновый импорт словаря ETIM через taxonomy_import_jobs

Импорт словаря DKC запускается через admin endpoint POST /admin/taxonomy/imports/etim/dkc → 202 + job_id. Фоновый runner в supplier-sync (гейт cfg.TaxonomyImport.RunnerEnabled, по умолчанию false) полить taxonomy_import_jobs WHERE status='pending', захватывает строку через UPDATE ... FOR UPDATE SKIP LOCKED, выполняет 5-фазный Importer, помечает completed/failed.

Единственный runner — serial queue. При старте runner сбрасывает все running записи в failed с причиной process_restart (защита от зависших job’ов после падения процесса).

7. Многовалютные наблюдения через колонку currency

Колонка offer_observations.currency добавлена миграцией 0066 (DEFAULT 'RUB'). ETM/IEK/Systeme получают RUB без изменений. DKC пишет отдельную строку offer_observations per-currency (RUB + KZT) через CursorObservationWriter.

Последствия

Плюсы

  • Федеративная таксономия unlocks будущие стандарты (ECLASS-12, GTIN, UNSPSC) без изменения схемы.
  • Cursor flow обрабатывает дельты каталога 1 млн+ позиций дёшево (только изменения за период тика).
  • Ручная правка canonical_external_identities защищена от автоматической перезаписи.
  • Бидирекциональный курсор (inbound DKC + future outbound B2B API) из одной таблицы.
  • taxonomy.Module() composable в обоих бинарниках (supplier-sync с активным runner’ом, api-server с dormant runner’ом) — fx optional deps через optional:"true" struct params.

Минусы

  • 17 новых таблиц/колонок (миграции 0058–0067) — schema surface значительно выросла.
  • Два pipeline’а (full_catalog + cursor) сосуществуют — операторы должны понимать, какой поставщик использует какой путь. Маршрутизация автоматическая (capabilities-driven), но troubleshooting требует знания обоих путей.

Риски и принятые компромиссы

  • TaxonomyImport runner однопоточный, глобальная очередь. Большой импорт может задержать меньший. Приемлемо для цикла 4 (только ETIM-7 от DKC); пересматривается при появлении второго стандарта.
  • Token cache только в памяти — потеря при рестарте безвредна (regenerable), но добавляет latency первого тика после рестарта (один HTTP round-trip на token exchange).

Рассмотренные альтернативы

Альтернатива A — Push-based incremental (DKC публикует события в Kafka)

DKC мог бы публиковать события изменений, которые Tracium потребляет асинхронно. Отклонено: DKC не имеет event surface и не планирует его. Poll-based cursor — единственный реалистичный вариант.

Альтернатива B — Единый canonical-словарь с маппингами из внешних стандартов

Слияние eClass, ETIM, GTIN в единый canonical-уровень на этапе импорта. Отклонено: это 6-месячная задача моделирования (разные гранулярности классов, пересекающиеся определения характеристик). Федеративные параллельные таблицы по стандарту дают рабочую инфраструктуру сейчас; мерж откладывается до реальной необходимости.

Альтернатива C — Повторное использование IngestOrchestrator.RunOnce для cursor flow

RunOnce итерирует по SKU через Enumerator и записывает наблюдения. Cursor flow мог бы эмулироваться через специальный Enumerator, читающий delta-эндпоинт. Отклонено: семантика TickReport (общее число SKU, счётчик ошибок) не совпадает с cursor mode (delta из N изменений поверх M-записей курсора); смешение создаёт ложные метрики. Отдельный CursorPipelineImpl с собственными метриками (cursor_ticks, cursor_advanced) семантически точнее.

Миграции

Описание
0058incremental_sync_cursor + индексы
0059taxonomy_identity_standards + сид ETIM-7
0060taxonomy_classes
0061taxonomy_features
0062taxonomy_values
0063taxonomy_units + taxonomy_class_features
0064characteristic_mappings (standard_code + external_feature_code → canonical)
0065canonical_external_identities
0066offer_observations.currency (DEFAULT ‘RUB’)
0067taxonomy_import_jobs

Новые переменные окружения

ПеременнаяДефолтНазначение
DKC_BASE_URL""URL DKC API. Пустая строка — модуль отключён
DKC_MASTER_KEY""master_key для token exchange
DKC_TOKEN_TTL24hTTL кэша access-token
TAXONOMY_IMPORT_RUNNER_ENABLEDfalsetrue только в supplier-sync env
TAXONOMY_IMPORT_POLL_INTERVAL10sИнтервал проверки pending jobs

Фазы реализации

Цикл 4 реализован за 37 коммитов в блоках:

  • F (миграции, 10 коммитов): 0058–0067
  • D (DKC domain + infra, 12 коммитов): TokenExchanger, IncrementalSyncCursor, DKC capabilities/session/connector/EnumeratorSeed, PipelineRouter, CursorPipelineImpl, IngestionMetrics extensions, supplier-sync DI
  • T (Taxonomy BC, 9 коммитов): domain + 8 PG repos + ETIM parser + Importer + JobsRunner
    • TaxonomyMetrics + admin HTTP
  • I (интеграция, 7 + 1 chore коммитов): ExternalIdentityWriter, canonical-identities admin gap-fill, DKC productFeature в cursor pipeline, supplier-sync runtime swap to PipelineRouter, CursorObservationWriter per-currency, api-server wired with taxonomy.Module()
  • C (closure, 4 коммита): ADR-0050, runbooks, memory snapshot

Deferred: C1 (DKC ingestion E2E), C2 (taxonomy import E2E) — отслеживаются как follow-up.

Связанные документы

  • Spec: docs/superpowers/specs/2026-04-30-dkc-adapter-and-taxonomy-design.md
  • Runbook (DKC cursor tick): docs/operations/dkc-cursor-tick.md
  • Runbook (taxonomy import): docs/operations/taxonomy-import.md
  • ADR-0024 — supplier credentials + auth schemas
  • ADR-0026 — marketplace observations + seller axis
  • ADR-0042 — ExchangeRate Layer 3a (unblocked DKC multi-currency)
  • ADR-0043 — ingestion orchestration audit (per-supplier tickers, advisory lock)
  • ADR-0046 / ADR-0047 / ADR-0049 — canonical assignments series (use canonical_external_identities)
  • Memory: tracium_bidirectional_cursor.md — cursor pattern rationale