Runbook: перенос правил подбора аналогов на живое дерево

Severity (default): P3 — плановая операция, не инцидент Owner: каталог / подбор аналогов Связанные алерты: секция P prod-health-check (покрытие правил), analog_rule_coverage_reports

Это не runbook инцидента. Это порядок разовой горячей мутации: перенос правил подбора аналогов с мёртвого supplier_native дерева на живое canonical. Операция самостоятельная — у неё своя метрика, своё окно и свой критерий отката. Она не является частью выкладки и не должна ехать прицепом к публикации дерева.

Почему это отдельная операция, а не флаг публикации

ANALOG_RULES_CARRY_ON_PUBLISH_ENABLED по умолчанию false на всех трёх слоях (код config.go, deploy/prod-env-template.env, deploy/docker/docker-compose.yml). Прежний дефолт true означал, что первая же публикация дерева, затеянная по любому поводу, молча выполнит запись порядка восьми тысяч строк в таблицу на горячем пути подбора — решение о переносе принимал бы не тот, кто его планировал.

Штатный путь — команда analog-rules-carry. Она считает тот же расчёт (Publisher.RunCarryTx), что выполнил бы шаг публикации, но отделяет показ от записи и даёт метрику до/после.

Метрика, по которой судят результат

Единственная метрика этой операции — покрытие правилами активных листьев canonical-дерева с канониками, с учётом наследования (ruleCoverageSQL):

  • знаменатель — активный лист текущей версии дерева, у которого есть хотя бы один каноник;
  • числитель — из этого множества те, над которыми по цепочке предков действует хотя бы одно невыключенное правило.

Наследование в числителе обязательно: правило на предке работает в рантайме (ResolveRules идёт вверх), и счёт только по прямым строкам занижал бы покрытие ровно там, где механизм работает.

Что этой метрикой не измеряется: качество подбора. Покрытие говорит, что у листа есть правило, а не что оно даёт верных кандидатов. Точность меряется разметкой якорей, и перенос её не заменяет и не приближает.

Замер на 2026-08-10 (tree_version = 2, пороги 25 / 0.9)

Снят read-only, командой показа на проде:

ВеличинаЗначение
Покрытие сейчас6 из 3105 листьев (0,19 %)
Прогноз после переноса597 из 3105 (19,2 %)
Целевых категорий505
Записей carry7745
Конфликтов источников791 (на 54 целях, максимум 52 на одну)
noop / delete0 / 0
Native-источников636, доля лидера 0,900 / 0,995 / 1,000 (мин / медиана / макс)

Виды переносимых правил: critical_match 3567, monotone_ge 1374, enum_compatible 1337, decorative 859, hard_divergence 453, monotone_le 155.

Прогноз «после» получен отдельным read-only запросом (лист считается покрытым, если целевая категория плана лежит на его цепочке предков) — он не является выводом самой команды и подлежит перепроверке перед окном, если дерево или правила менялись.

Точка восстановления

Полный дамп базы для этой операции не нужен и вреден. Он идёт ~2,5 часа, насыщает I/O и держит ACCESS SHARE на всех таблицах — тем и сорвал деплой 2026-07-14 (см. db-vps-backup-tier.md). Перенос пишет ровно в две таблицы, вместе они занимают 4,6 МБ.

Снять точку непосредственно перед окном:

ssh tracium-db
ts=$(date -u +%Y%m%dT%H%M%SZ)
docker exec tracium-postgres-1 pg_dump -U tracium -d tracium \
  --format=custom --no-owner \
  --table=category_analog_rules \
  --table=analog_rule_coverage_reports \
  --file=/tmp/carry-restore-$ts.dump
docker cp tracium-postgres-1:/tmp/carry-restore-$ts.dump /tmp/
sudo install -d -m 0750 -o root -g root /backup-raid/manual
sudo mv /tmp/carry-restore-$ts.dump /backup-raid/manual/
sudo ls -la /backup-raid/manual/carry-restore-$ts.dump   # размер ≠ 0 обязателен

/backup-raid принадлежит root (0750) — без sudo копирование молча провалится, а оператор узнает об этом уже после записи. Класть дамп в /tmp и оставлять там нельзя: том DB-VPS — тот самый, который забивался до 100 % в инциденте 2026-07-30.

Восстановление из этого дампа — последнее средство, не основной откат (основной — удаление по run_id, см. ниже). pg_restore --data-only в непустую таблицу упрётся в первичный ключ на уже существующих строках, поэтому восстановление возможно только после того, как таблица приведена к состоянию дампа, и выполняется целиком в одной транзакции:

sudo docker cp /backup-raid/manual/carry-restore-<ts>.dump tracium-postgres-1:/tmp/r.dump
docker exec tracium-postgres-1 psql -U tracium -d tracium -v ON_ERROR_STOP=1 \
  -c "DELETE FROM category_analog_rules WHERE source = 'carried'"
docker exec tracium-postgres-1 pg_restore -U tracium -d tracium \
  --data-only --single-transaction \
  --table=category_analog_rules /tmp/r.dump

DELETE ограничен source='carried': прямые правила (llm, manual, seed — на 2026-08-10 их 15 684) в дампе те же самые, и трогать их нечем и незачем.

Ночной borg-архив (tracium-backup@tracium-postgres.timer, 00:30 UTC, хранение 7 суточных) — вторая линия, а не первая: восстановление из него означает возврат всей базы на сутки назад.

Шаг 1. Read-only показ — точный план записей

Соединение открывается с default_transaction_read_only=on: запись невозможна физически, а не по договорённости о флагах.

ssh tracium-db
cd /srv/tracium/infra/deploy/docker
COMPOSE="docker compose --env-file /srv/tracium/infra/.env \
  --env-file /srv/tracium/infra/deploy/.env"
$COMPOSE run --rm --no-deps -T \
  --entrypoint /usr/local/bin/analog-rules-carry \
  category-classifier > /tmp/carry-plan.json 2>/tmp/carry-plan.log
tail -1 /tmp/carry-plan.log

JSON в stdout, журнал в stderr. Отчёт содержит по каждой цели: источники с долей и размером выборки, и решение по каждому ключу с провенансом (from_native_id, sample_size, share) для тех, что будут записаны.

Разобрать итоги:

python3 - <<'PY'
import json, collections
d = json.load(open('/tmp/carry-plan.json'))
t = d['totals']
print('дерево:', d['tree_version'], '| пороги:', d['min_sample'], d['min_share'])
print('целей:', len(d['targets']), '| carry:', t['carried'],
      '| conflicts:', t['conflicts'], '| delete:', t['deleted'])
print('покрытие до: %d / %d' % (t['coverage_before_leaves_with_rules'],
                                t['coverage_before_populated_leaves']))
k = collections.Counter(a.get('kind') for x in d['targets']
                        for a in x['actions'] if a['action'] == 'carry')
print('виды правил:', dict(k))
PY

Не переходить к записи, если план разошёлся с ожидаемым:

  • delete > 0 при первом прогоне — переносить нечего было, удалять тем более: carried-строк в базе нет вовсе. Ненулевое значение означает, что состояние не то, из которого снят этот runbook.
  • доля лидера у источника ниже 0,9 — невозможно при штатных порогах; означает, что пороги переопределены флагами.
  • целей радикально больше 505 при неизменном дереве — расследовать до окна.

Шаг 2. Запись

Требует непустого -actor: правка живой выдачи должна быть именной.

$COMPOSE run --rm --no-deps -T \
  --entrypoint /usr/local/bin/analog-rules-carry \
  category-classifier -apply -actor="<кто>" \
  > /tmp/carry-apply.json 2>/tmp/carry-apply.log
tail -3 /tmp/carry-apply.log

Показ и запись идут одной транзакцией реализации: применённое не может разойтись с показанным из-за изменения базы между двумя командами. Но между шагом 1 и шагом 2 база живёт — план пересчитывается заново, поэтому окно должно быть коротким, а итоги двух прогонов сверены.

Сохранить run_id из отчёта — он единственный ключ отката.

Шаг 3. Проверка после

python3 -c "
import json; t=json.load(open('/tmp/carry-apply.json'))['totals']
print('до: %d/%d' % (t['coverage_before_leaves_with_rules'], t['coverage_before_populated_leaves']))
print('после:', '%d/%d' % (t['coverage_after_leaves_with_rules'], t['coverage_after_populated_leaves'])
      if t['coverage_after_known'] else 'НЕ ИЗМЕРЕНО')
print('отчёт покрытия сохранён:', t['coverage_report_saved'])"
-- то же самое из базы
SELECT source, count(*) FROM category_analog_rules GROUP BY source ORDER BY 1;
SELECT DISTINCT evidence->>'run_id' FROM category_analog_rules WHERE source = 'carried';

Ожидаемое coverage_report_saved = false при первом прогоне. Таблица analog_rule_coverage_reports сейчас пуста: команда обновляет столбцы покрытия существующей строки версии дерева, а создаёт строку только публикация. Это не сбой — замер виден в JSON-отчёте, но до ближайшей публикации дерева секция P prod-health-check будет честно показывать «отчёта для tree_version=N нет». Оператор, не знающий этого, примет штатное состояние за поломку.

Порог RULE_COVERAGE_MIN_PCT в health-check равен 15: прогнозные 19,2 % проходят, но с небольшим запасом — покрытие ниже 15 % даст предупреждение.

Критерий отката

Откатывать, если после записи выполнено любое из:

  1. Покрытие не вырослоcoverage_after_leaves_with_rules не больше coverage_before_leaves_with_rules. Операция, не сдвинувшая свою единственную метрику, не имеет оснований оставаться.
  2. Покрытие выросло существенно меньше прогноза — ниже 400 из 3105 (две трети от прогнозных 597). Означает, что отображение категорий разошлось с замером и план считался по другому состоянию базы.
  3. Подбор аналогов деградировал — на эталонных якорях (analogs-verification) появились кандидаты из чужого вида изделия либо выдача опустела там, где была непустой. Это то, чего метрика покрытия по устройству не видит.

Откат — удаление строк своего прогона, прямые правила (manual, seed, llm) условие не затрагивает по определению:

DELETE FROM category_analog_rules
 WHERE source = 'carried' AND evidence->>'run_id' = '<run_id>';

Откат безопасен при deleted = 0 в отчёте: перенос ничего не удалял, поэтому удаление его собственных строк возвращает ровно исходное состояние. Если в отчёте deleted > 0, восстановление возможно только из дампа шага «Точка восстановления» — удалённые строки run_id не помнят.

После отката проверить, что счётчик вернулся:

SELECT count(*) FROM category_analog_rules WHERE source = 'carried';  -- ожидаем 0

Чего эта операция не делает

  • Не улучшает качество подбора — переносит существующие правила туда, где они начнут действовать. Правило, дававшее плохих кандидатов на мёртвом дереве, будет давать их же на живом.
  • Не заменяет разметку якорей. Вопрос «умеем ли мы ранжировать» решается разметкой, а не покрытием.
  • Не создаёт identity-осей. Их в проде 0 из 230 376 (characteristic-significance), и перенос правил этого не меняет.

Эскалация

  • Транзакция не уложилась в TAXONOMY_PUBLISH_STATEMENT_TIMEOUT (8m) — повторить в окне низкой нагрузки: тяжёлая сторона расчёта (categoryMappingSQL: supplier_offers ⋈ canonical_category_assignments) на холодной базе занимала 61,0 с против 2,98 с на тёплой.
  • Ошибка platform.storage.io — смотреть I/O DB-VPS (db-vps-io-saturation.md), не повторять команду вслепую.

Связано

  • category-tree-publish.md — публикация дерева; перенос из неё выключен
  • db-vps-backup-tier.md — почему полный дамп не годится как точка перед окном
  • matching-and-analogs-search.md — контур подбора, на который влияет перенос
  • backend/cmd/analog-rules-carry/main.go, backend/internal/core/catalog/taxonomy_builder/infra/postgres/publisher_carry.go