Публикация дерева категорий — операторский запуск

Публикация черновика дерева категорий (POST /api/v1/admin/catalog/categories/builds/{id}/publish) больше не выполняется по HTTP. Начиная с блокера C3 (.superpowers/sdd/2026-08-09-analog-rules-carry-forward/critical-c3-report.md), эндпоинт всегда отвечает 501 с понятным текстом отказа, а сама публикация выполняется только версионированным операционным джобом category-tree-publish в GitLab CI.

Почему так

Publish — многоминутная транзакция: она активирует узлы дерева и мигрирует привязки характеристик, с бюджетом TAXONOMY_PUBLISH_STATEMENT_TIMEOUT (default 8 минут). Это дольше:

  • API_HTTP_WRITE_TIMEOUT (60с) на самом api-server;
  • proxy_read_timeout 60s на edge-nginx перед ним.

При обрыве на этом бюджете HTTP-клиент получает разрыв соединения, но транзакция на сервере продолжает выполняться — оператор не узнаёт, дошла публикация до конца или нет, и в каком состоянии осталось дерево.

Как запустить

  1. Получить build_id черновика: GET /api/v1/admin/catalog/categories/builds?status=draft (или из ответа POST /builds, которым черновик был создан).
  2. Открыть в GitLab пайплайн нужного коммита (main, тот же SHA, что уже раскатан выкладкой — deploy-db-vps в этом пайплайне должен быть зелёным).
  3. Найти ручную джобу category-tree-publish (стадия release) и запустить её, задав переменную TAXONOMY_BUILD_ID равной UUID черновика из шага 1. Пустое значение джоба ловит до ssh на DB-VPS и явно отказывает — не уходит в базу с мусорным аргументом.
  4. Прочитать лог джобы: она печатает SHA образа (tracium/worker:$CI_COMMIT_SHA), build_id и исход публикации — строку отчёта category-tree-publish, что и в CLI (published build ... — N nodes activated (tree vK; ...; analog rules carry enabled=... carried=N)), либо ошибку с кодом выхода ≠ 0, если публикация не удалась.

Джоба ручная (when: manual), без allow_failure — красный джоб означает настоящий провал публикации, а не «temporarily disabled» галочку. У неё нет зависимости от CI_DB_BUILDER_ENABLED: образ уже гарантирован джобой build-workers, из того же релизного образа воркеров и того же $CI_COMMIT_SHA, что уехал в deploy-db-vps.

Что бинарник трогает на хосте

Джоба SSH’ится на DB-VPS и запускает разовый контейнер тем же приёмом, каким выкладка гоняет миграции (deploy-db-vps.sh) и каким пилот семейств запускает family-axis-rebuild (см. product-family-pilot-ups.md, шаг 7):

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/category-tree-publish \
  category-classifier --build-id=<uuid> --by=<кто>

Контейнер поднят под category-classifier, а не под произвольным сервисом: именно в его environment: в deploy/docker/docker-compose.yml проброшен TAXONOMY_PUBLISH_STATEMENT_TIMEOUT (и там же — ключи переноса) — запуск под другим сервисом (например, supplier-sync) молча потерял бы эти переменные, и публикация словила бы дефолты кода, а не настроенные значения (тот же класс отказа, что закрывал C4/C5).

Перенос правил аналогов публикацией НЕ выполняется

ANALOG_RULES_CARRY_ON_PUBLISH_ENABLED по умолчанию false: перенос правил подбора на новое дерево — самостоятельная горячая операция со своей метрикой и своим окном, а не побочный эффект публикации. Порядок — analog-rules-carry.md.

Практическое следствие для этого runbook’а: публикация не изменит покрытие правилами и не создаст запись в analog_rule_coverage_reports. Если покрытие ожидалось — оно не появится, и искать причину в публикации не надо.

Шаг ПОСЛЕ публикации: перезапустить prod-health-check

Секция P (покрытие правил подбора аналогов) считается только в джобе prod-health-check, а она идёт в стадии deploy — то есть до стадии release, в которой публикуется дерево. Значит сразу после публикации health-check покажет числа предыдущего состояния до следующего пайплайна main.

Перезапустите prod-health-check вручную и прочитайте секцию P.

Что означают три состояния секции:

ВыводСмысл
⚠ отчёта для tree_version=N нетштатно, пока перенос (analog-rules-carry) не выполнялся на этой версии дерева
✗ покрытие ниже предыдущегопокрытие упало — разбираться до следующей публикации
✓ покрытие N/M, прямых целей Kдва числа расходятся из-за наследования правил, и это нормально

Откат

Публикация обратима на уровне дерева: POST .../taxonomy/rollback/{tree_version} по HTTP либо тот же бинарник с --rollback --to-version=<предыдущая версия>. Откат двигает указатель назад и не требует пересборки черновика.

Что откат НЕ делает: если перенос когда-либо выполнялся отдельной операцией, его carried-строки остаются на своих категориях — идентификаторы категорий между версиями стабильны, а строки живут в category_analog_rules, а не в дереве. Снимаются они своим откатом по run_id, а не откатом дерева (analog-rules-carry.md).

Публикация как таковая горячих строк не пишет: перенос из неё выключен (см. выше), поэтому отдельная точка восстановления под category_analog_rules здесь не требуется — она требуется под операцию переноса.

Прочие ручки хендлера не меняются

GET .../builds, GET .../builds/{id}, GET .../taxonomy/diff/{from}/{to}, POST .../taxonomy/rollback/{tree_version} и POST .../builds/{id}/discard по-прежнему работают по HTTP как раньше — они быстрые и не упираются в 60-секундный бюджет. Джоб category-tree-publish покрывает только Publish.

Ручной запуск CLI напрямую (эскалация)

Если GitLab недоступен, тот же бинарник можно запустить вручную с той же командой, что использует джоба — обязательно тем же тегом образа, что сейчас раскатан (docker compose config | grep image на category-classifier подтвердит текущий SHA). Запуск с произвольного/устаревшего образа — ровно та проблема, ради устранения которой публикатор переехал из отдельно собираемого tools-образа в общий релизный.

Связано

  • .superpowers/sdd/2026-08-09-analog-rules-carry-forward/critical-c3-report.md
  • docs/docs/40-operations/runbooks/product-family-pilot-ups.md (тот же приём запуска разовой команды из runtime-образа)
  • backend/cmd/category-tree-publish/main.go