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 |
Записей carry | 7745 |
| Конфликтов источников | 791 (на 54 целях, максимум 52 на одну) |
noop / delete | 0 / 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.dumpDELETE ограничен 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.logJSON в 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 % даст предупреждение.
Критерий отката
Откатывать, если после записи выполнено любое из:
- Покрытие не выросло —
coverage_after_leaves_with_rulesне большеcoverage_before_leaves_with_rules. Операция, не сдвинувшая свою единственную метрику, не имеет оснований оставаться. - Покрытие выросло существенно меньше прогноза — ниже 400 из 3105 (две трети от прогнозных 597). Означает, что отображение категорий разошлось с замером и план считался по другому состоянию базы.
- Подбор аналогов деградировал — на эталонных якорях
(
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