Runbook: нормализация и объединение производителей
Severity (default): P2
Owner: Catalog / IT Lead
Инструмент: manufacturer-identity-merge
Симптом
Один производитель виден в каталоге под разными написаниями (finder,
FINDER, Finder) либо связан с несколькими manufacturer_id. Это дробит
поиск, подборы и агрегации даже тогда, когда UI-дефект выглядит только как
нарушение регистра.
Исправление двухфазное:
- runtime-resolver сводит новые accepted-варианты на один active ID и сохраняет
каждое исходное написание в
manufacturer_alias_observations; - операторская команда объединяет только доказанные существующие дубли, переносит ссылки, создаёт 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 или неожиданно большой объём:
- не передавать
-apply; - сохранить отчёт и SQL/логи диагностики;
- исправить модель либо решение модератора отдельным MR;
- после 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.
Связано
migration-deployed-without-code.mddb-vps-io-saturation.mdcanonical-identity-rekey-cutover.mddocs/superpowers/specs/2026-08-24-manufacturer-identity-normalization-and-merge-design.md