Runbook: нормализация и объединение производителей

Severity (default): P2

Owner: Catalog / IT Lead

Инструмент: manufacturer-identity-merge

Симптом

Один производитель виден в каталоге под разными написаниями (finder, FINDER, Finder) либо связан с несколькими manufacturer_id. Это дробит поиск, подборы и агрегации даже тогда, когда UI-дефект выглядит только как нарушение регистра.

Исправление двухфазное:

  1. runtime-resolver сводит новые accepted-варианты на один active ID и сохраняет каждое исходное написание в manufacturer_alias_observations;
  2. операторская команда объединяет только доказанные существующие дубли, переносит ссылки, создаёт direct redirect и оставляет точный аудит для восстановления.

LLM-only, similarity, транслитерация и удаление юридических суффиксов остаются review-кандидатами. Они никогда не попадают в автоматический apply.

Диагностика

1. Проверить версию схемы и runtime

Операция допустима только после обычного deploy из main, в котором одной ревизией поставлены runtime-код и миграции:

  • 0381_manufacturer_identity_runtime.sql;
  • 0382_manufacturer_identity_merge_audit.sql;
  • 0383_supplier_offers_manufacturer_alias_lookup_idx.sql;
  • 0384_supplier_offers_manufacturer_reference_idx.sql.

На DB-VPS:

ssh tracium-db
docker exec tracium-postgres-1 psql -U tracium -d tracium -v ON_ERROR_STOP=1 -c \
  "SELECT max(version_id) AS goose_version
     FROM goose_db_version
    WHERE is_applied"

Ожидается goose_version >= 384. Перед dry-run дополнительно проверить, что индексы поиска raw-написаний и fingerprint ссылок доступны planner:

SELECT indexrelid::regclass AS index_name, indisready, indisvalid
  FROM pg_index
 WHERE indexrelid IN (
       to_regclass('public.supplier_offers_manufacturer_alias_lookup_idx'),
       to_regclass('public.supplier_offers_manufacturer_reference_idx')
 )
 ORDER BY index_name;

Ожидается одна строка с indisready=true и indisvalid=true. Миграция строит индекс concurrently: до завершения validation schema остаётся на предыдущей версии, а dry-run не запускается. Это исключает полный проход CLI по 17-ГБ supplier_offers: accepted aliases входят в функциональный индекс по manufacturer_alias_key(manufacturer_name), а checksum exact offer IDs читается из покрывающего (manufacturer_id, id) без random heap reads.

Красный deploy-db-vps сам по себе не доказывает отсутствие миграций: проверяется база, а затем SHA реально раскатанного worker image.

2. Собрать maintenance image того же SHA

manufacturer-identity-merge намеренно находится только в полном tools-образе, а не в штатном runtime. В GitLab-пайплайне уже раскатанного SHA вручную запустить maintenance job для активного builder mode:

  • build-worker-tools-dind, пока CI_DB_BUILDER_ENABLED != 1;
  • build-worker-tools, когда включён DB-VPS BuildKit builder.

Джоба должна опубликовать:

192.168.1.85:5001/tracium/worker-tools:<40-hex-release-sha>

Если manual job недоступна или tools SHA отличается от runtime/schema SHA — остановиться. Ad-hoc сборка на production-хосте не используется.

3. Подготовить неизменяемый каталог отчётов

cd /srv/tracium/infra/deploy/docker
RELEASE_SHA='<40-hex-release-sha>'
TOOLS_IMAGE="192.168.1.85:5001/tracium/worker-tools:${RELEASE_SHA}"
REPORT_DIR="/srv/tracium/manufacturer-identity/${RELEASE_SHA}"
COMPOSE="docker compose --env-file /srv/tracium/infra/.env --env-file /srv/tracium/infra/deploy/.env"
 
sudo install -d -m 0700 "$REPORT_DIR"
docker pull "$TOOLS_IMAGE"
docker image inspect "$TOOLS_IMAGE" --format '{{.Id}} {{index .RepoDigests 0}}'

Не менять /srv/tracium/infra/deploy/.env ради этой операции. Runtime-конфиг остаётся deploy-managed; DSN команда получает из окружения supplier-sync.

4. Выполнить только read-only dry-run

TRACIUM_WORKER_IMAGE="$TOOLS_IMAGE" $COMPOSE run --rm --no-deps -T \
  -e ENVIRONMENT=prod \
  -e CALIBRATION_MODE=off \
  -v "$REPORT_DIR:/work" \
  --entrypoint /usr/local/bin/manufacturer-identity-merge \
  supplier-sync \
  -requested-by 'maxim' \
  -report /work/dry-run.json \
  -statement-timeout 2m \
  -lock-timeout 5s

Без -apply пул PostgreSQL открывается с default_transaction_read_only=on: команда не может записать данные по устройству соединения. Dry-run проверяет фактический FK inventory и откажется, если появился неизвестный FK на manufacturers.

Сохранить два разных checksum:

jq '{version, mode, report_checksum, candidate_rows, safe_groups,
     review_groups, blocked_groups, safe_apply_estimate}' \
  "$REPORT_DIR/dry-run.json"
sha256sum "$REPORT_DIR/dry-run.json" > "$REPORT_DIR/dry-run.json.sha256"
chmod 0400 "$REPORT_DIR/dry-run.json" "$REPORT_DIR/dry-run.json.sha256"

report_checksum внутри JSON — checksum содержания плана, который принимает apply. sha256sum — дополнительная проверка файла на хосте; они не обязаны совпадать.

5. Что проверить в отчёте

  • mode == "dry-run", version == 1;
  • blocked_groups разобраны по blocked_reason; они не применяются;
  • review_groups содержат только кандидатов для человека и не применяются;
  • у каждой safe-группы все victims имеют case_key, moderator или seed;
  • safe_apply_estimate содержит ожидаемые количества victims, offers, canonicals, observations, aliases, redirects и projector enqueues;
  • survivor выбран детерминированно: confirmed display, затем больше ссылок, более старая запись и UUID;
  • варианты из отчёта действительно обозначают одну компанию, а не похожие бренды.

Для разбора классов и блокировок:

jq -r '.plans[] |
  [.survivor_id, (.auto_apply|tostring), (.blocked_reason // ""),
   ([.victims[].evidence] | unique | join(",")),
   ([.victims[].raw_names[]?] | unique | join(" | "))] | @tsv' \
  "$REPORT_DIR/dry-run.json" > "$REPORT_DIR/groups.tsv"

Смягчение

До одобрения merge достаточно задеплоить runtime-фазу: новые raw-написания сохраняются, accepted aliases разрешаются до записи оффера, а проектор берёт официальный display по active identity. Это останавливает дальнейшее размножение проблемы без физического объединения исторических IDs.

Если dry-run показал неизвестный FK, цикл, multiple target, alias conflict или неожиданно большой объём:

  1. не передавать -apply;
  2. сохранить отчёт и SQL/логи диагностики;
  3. исправить модель либо решение модератора отдельным MR;
  4. после deploy создать новый dry-run и новый checksum.

Устранение root cause

1. Approval gate

Production apply — отдельное изменение данных с высоким blast radius. Он разрешён только после явного одобрения конкретного report_checksum. Фраза «продолжай» до появления production dry-run не является одобрением его checksum.

Перед apply повторно проверить файл:

sha256sum -c "$REPORT_DIR/dry-run.json.sha256"
APPROVED_CHECKSUM=$(jq -er '.report_checksum' "$REPORT_DIR/dry-run.json")
printf '%s\n' "$APPROVED_CHECKSUM"

2. Apply первого малого пакета

Первый production-пакет — не более 10 safe-групп и вне окна backup/пикового I/O. Команда сначала идемпотентно backfill’ит точные historical raw spellings, после чего применяет только первые max-groups safe-групп из одобренного отчёта.

TRACIUM_WORKER_IMAGE="$TOOLS_IMAGE" $COMPOSE run --rm --no-deps -T \
  -e ENVIRONMENT=prod \
  -e CALIBRATION_MODE=off \
  -v "$REPORT_DIR:/work" \
  --entrypoint /usr/local/bin/manufacturer-identity-merge \
  supplier-sync \
  -apply \
  -requested-by 'maxim' \
  -approved-report /work/dry-run.json \
  -approved-checksum "$APPROVED_CHECKSUM" \
  -report /work/apply-001.json \
  -max-groups 10 \
  -batch 100 \
  -statement-timeout 2m \
  -lock-timeout 5s

Команда не может перезаписать approved report. Для каждой группы она заново читает состояние внутри транзакции, сравнивает plan checksum, берёт advisory locks, сохраняет entity/link backups и только затем переносит ссылки. Ошибка откатывает текущую группу.

Повтор ровно той же команды не переходит к следующей десятке: уже выполненные события возвращаются как idempotent. Для следующего пакета обязателен новый dry-run после перепроекции и новое явное одобрение нового checksum.

3. Проверить аудит и ссылки после каждого пакета

Подставить event_id из apply_results:

WITH event AS (
    SELECT id, survivor_manufacturer_id, summary
      FROM manufacturer_merge_events
     WHERE id = :'event_id'::uuid
), victims AS (
    SELECT old_manufacturer_id
      FROM manufacturer_merge_members
     WHERE merge_event_id = :'event_id'::uuid
)
SELECT
    (SELECT status FROM manufacturer_merge_events WHERE id=:'event_id'::uuid) AS status,
    (SELECT count(*) FROM supplier_offers WHERE manufacturer_id IN (SELECT old_manufacturer_id FROM victims)) AS offer_refs_left,
    (SELECT count(*) FROM canonical_products WHERE manufacturer_id IN (SELECT old_manufacturer_id FROM victims)) AS canonical_refs_left,
    (SELECT count(*) FROM manufacturer_aliases WHERE manufacturer_id IN (SELECT old_manufacturer_id FROM victims)) AS alias_refs_left,
    (SELECT count(*) FROM manufacturer_alias_observations WHERE resolved_manufacturer_id IN (SELECT old_manufacturer_id FROM victims)) AS observation_refs_left,
    (SELECT count(*) FROM manufacturer_redirects WHERE survivor_manufacturer_id IN (SELECT old_manufacturer_id FROM victims)) AS redirect_refs_left;

Ожидается status=applied и нули во всех *_refs_left. Дополнительно:

SELECT table_name, count(*) AS backed_rows
  FROM manufacturer_merge_link_backups
 WHERE merge_event_id = :'event_id'::uuid
 GROUP BY table_name
 ORDER BY table_name;
 
SELECT raw_name, observations, first_seen_at, last_seen_at
  FROM manufacturer_alias_observations
 WHERE resolved_manufacturer_id = (
       SELECT survivor_manufacturer_id
         FROM manufacturer_merge_events
        WHERE id=:'event_id'::uuid)
 ORDER BY raw_name;

Проверить, что catalog-projector съел поставленные события, затем открыть затронутые карточки/подборы: display единый, а товары не исчезли. Коммерческие цена/остаток/доставка не участвуют в eligibility merge.

После перепроекции повторить секцию X prod-health-check. Счётчик manufacturer split должен уменьшиться для применённых safe-групп. provider-code conflict может остаться: это корректный отказ склеивать разные товары по групповому коду и он выводится информационно. projection blocked означает отдельный canonical identity-инвариант и не разрешается принудительным manufacturer merge.

4. Restore одного события

Restore допустим только до последующей модерации/merge участников. Он сверяет post-state checksum и точные ссылки; при любом дрейфе отказывает, а не перетирает новые данные.

EVENT_ID='<uuid-from-apply-report>'
TRACIUM_WORKER_IMAGE="$TOOLS_IMAGE" $COMPOSE run --rm --no-deps -T \
  -e ENVIRONMENT=prod \
  -e CALIBRATION_MODE=off \
  -v "$REPORT_DIR:/work" \
  --entrypoint /usr/local/bin/manufacturer-identity-merge \
  supplier-sync \
  -restore-event "$EVENT_ID" \
  -requested-by 'maxim' \
  -report "/work/restore-${EVENT_ID}.json" \
  -statement-timeout 2m \
  -lock-timeout 5s

После restore ожидается status=restored, восстановление exact backups и новые manufacturer.identity.restored записи в projector queue.

Условия немедленной остановки

  • неизвестный FK в fk_inventory.unknown;
  • любой checksum mismatch или plan_drift;
  • alias/redirect conflict либо цикл;
  • lock/statement timeout;
  • рост DB I/O, lag projector или long-running transaction выше операционного бюджета;
  • неожиданное уменьшение числа offers/canonicals;
  • объединение разных реальных брендов в выборочной проверке.

Не увеличивать таймауты и batch на ходу. Сначала остановить прогон, сохранить отчёт и разобрать причину.

Эскалация

  • Owner: Catalog / IT Lead.
  • При неоднозначной идентичности — ValueNorm moderator; auto-merge не включать.
  • При неизвестном FK или drift схемы — backend owner до нового deploy.
  • При DB I/O/lock saturation — следовать db-vps-io-saturation.md.

Связано