getattr вместо прямого импорта LISTINGS_FRESH_DAYS из config: на origin/main
константы там ещё нет, и тест умирал ImportError'ом на сборке модуля —
«возможности нет» вместо «значение неверно». Теперь на main: 5 красных
ассертами (предикат отсутствует/окно None/протухшие комплы в пуле), 3 зелёных
(сброс anchor_tier уже влит отдельно).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Внешний basic_auth Caddy снят с витрины по решению владельца: два запроса
пароля подряд мешали работе, а у Grafana есть собственная аутентификация с
ролями и GF_USERS_ALLOW_SIGN_UP=false.
Что теряется, чтобы решение было осознанным: basic_auth отсекал сканеры до
Grafana и прикрыл бы её собственную будущую уязвимость. Теперь страница входа
видна из интернета напрямую. Это записано комментарием прямо в конфиге, чтобы
через полгода не выглядело недосмотром.
Приём метрик НАРОЧНО остаётся под basic_auth: туда ходит агент по паролю,
который лежит в открытом виде в окружении продуктового хоста, и отдельная
учётка там ограничивает ущерб записью. Снят ровно один слой и ровно с витрины.
Файл metrics-ui.caddy.snippet и его bind-mount оставлены на месте: диффу так
меньше, вернуть слой можно одной строкой. Гард импортов (#3104) проверяет
обратное направление - что каждый import покрыт маунтом, - поэтому лишний
маунт его не трогает.
Проверено: scripts/check-caddy-snippet-mounts.py зелёный (6 конфигов,
7 маунтов); конфиг с новой версией infra.caddy проходит caddy validate в
одноразовом контейнере на Beget - Valid configuration.
Продолжение после yandex-свипа. Выбор источника — по замеру, а не «для
полноты»: за 60 дней avito_city_sweep дал 68 прогонов, 25 банов и 3 отмены
деплоем при среднем времени 15 мин и максимуме 97.
Прод-факт, который решает дело. Типичный итог свипа:
{"anchors_done": 1, "anchors_total": 5, ...,
"enrichment_abort_note": "detail enrichment aborted (Avito detail
firewall/soft-block ...)"}
Прогон срывается блокировкой на ПЕРВОМ из пяти якорей. Без чекпоинта
следующий прогон снова идёт в первый якорь, упирается в ту же стену, и якоря
2-5 не собираются никогда.
Ключ чекпоинта — ИМЯ якоря, а не индекс: состав списка зависит от city_slug,
позиция в нём между городами не устойчива. По той же причине здесь не нужна
гарда по числу якорей, которая есть у combo-чекпоинта яндекса: там ключ
якоря не содержал, здесь якорь и есть ключ.
Инвариант, ради которого отдельный флаг _anchor_ok: в чекпоинт попадает
только якорь, пройденный до конца. Ветка блокировки делает return и до записи
не доходит, а generic-except доходит — якорь упал, но цикл продолжается.
Записать такой якорь пройденным значило бы, что следующий прогон пропустит
его навсегда, причём молча: прогон завершится штатно, просто часть города не
соберётся. Флаг сбрасывается на каждой итерации, иначе один упавший якорь
заразил бы все последующие.
Пропущенный якорь двигает anchors_done — чтобы счётчик продолжал означать
«докуда дошли по списку», а не «сколько собрал именно этот прогон».
domclick_city_sweep намеренно НЕ трогаю: 54 прогона, ноль отмен деплоем,
среднее время 3 минуты — чекпоинт там не окупается. cian_city_sweep (среднее
35 мин, 2 отмены) — следующий шард.
Тесты (4) поведенческие: якорь из чекпоинта не опрашивается вовсе; пройденный
дописывается поверх унаследованных; без чекпоинта обходятся все; упавший в
чекпоинт НЕ попадает. Оговорка: на исходном коде они падают по сигнатуре
(unexpected keyword argument), то есть доказывают отсутствие параметра, а не
поведение — поведенческую часть держат сами проверки. Весь набор #3074 —
10 passed.
Бот, которым шлются тревоги, — тот же, что пересылает сообщения поддержки, а
его чат форумный. Без message_thread_id Alertmanager кладёт тревоги в общую
тему, вперемешку с клиентской перепиской.
Поле поддерживается: проверено amtool check-config на том же образе, что
поднимается в проде (prom/alertmanager:v0.28.0). Схема Alertmanager строгая и
неизвестные поля отвергает, так что успешная проверка означает именно
поддержку, а не молчаливое игнорирование.
Подставляется ЦЕЛАЯ СТРОКА, а не значение: envsubst не умеет условий, и при
шаблоне вида `message_thread_id: ${TOPIC_ID}` незаданный топик дал бы
`message_thread_id:` без значения. Это не деградация - Alertmanager с таким
конфигом не стартует вовсе, то есть алертинг исчезает целиком. Деплой
формирует либо всю строку с отступом, либо пустую.
Топик необязателен: без него поле отсутствует, алерты уходят в общую тему,
поведение прежнее.
Попутно добавлена проверка конфига через amtool ДО подъёма стека - по образцу
`caddy validate` ниже в этом же файле. amtool берётся из того же образа, что и
сам Alertmanager, иначе проверялась бы не та версия схемы. Битый конфиг теперь
роняет деплой громко, а не выключает алертинг тихо.
Тесты (4) рендерят шаблон обоими способами и разбирают результат как YAML -
проверяется фактический конфиг, а не наличие нужных слов в тексте. Отдельно
проверено, что переменная объявлена в списке envsubst: забыть её - значит
оставить в конфиге литерал плейсхолдера.
Фальсификация: на исходных файлах краснеют 3 из 4; проходит только тест,
фиксирующий сохранённое поведение при незаданном топике. tests/ops целиком -
35 passed.
#3108 не починил пустые панели по контейнерам, и правило relabel там было ни
при чём. Настоящая причина глубже: Docker 29 на обоих хостах работает через
containerd-snapshotter (Storage Driver = overlayfs), а cAdvisor 0.52 ищет
метаданные слоя в легаси-хранилище:
failed to identify the read-write layer ID for container "<id>"
- open /rootfs/var/lib/docker/image/overlayfs/layerdb/mounts/<id>/mount-id:
no such file or directory
При снапшоттере этого каталога нет вовсе — в /var/lib/docker/image/ лежит
только identity-cache.db, метаданные слоёв живут в containerd. Обработчик
контейнера не создаётся, и наружу уходит ровно один ряд: корневой cgroup
container_last_seen{id="/"}. Отсюда и «нодата» на панелях контейнеров при
живых node/postgres/app метриках.
0.55.1 умеет читать containerd-снапшоттер. Проверено пробами с ПРОДОВЫМИ
флагами на обоих хостах (одноразовые контейнеры, убраны за собой):
Beget (Docker 29.4.1): 22 ряда, все с name=, ошибок rw-layer 0
Poincare (Docker 29.7.2): 23 ряда, все с name=, ошибок rw-layer 0
Примеры рядов — name="gendesign-alloy", name="gendesign-backend-1",
name="gendesign-osrm-1", с лейблом image. То есть именно то, чего не хватало
панелям.
Заодно поправлен комментарий, который я же вписал в cadvisor_trim в #3108: он
объяснял пустые панели трактовкой пустого regex, а это оказалось неверно.
Правило корректно и остаётся (корневой ряд приходит с name="" и должен
отсеиваться), но объяснение рядом с ним вводило в заблуждение.
Почему тег .1, а не .0: в реестре нет ни v0.53.0, ни v0.54.0, ни v0.55.0 —
только v0.54.1 и v0.55.1. Проверял по списку тегов, а не подбором.
Проверки: yaml.safe_load compose — ok; alloy fmt обоих .alloy в
grafana/alloy:v1.6.1 — exit 0.
Три независимых дефекта, найденных на живом проде после подъёма стека метрик.
1. cAdvisor-метрики не доезжали ВООБЩЕ. В Prometheus ноль имён container_*
при 2034 именах всего, хотя cAdvisor отдаёт 880 рядов, scrape-таргет в
alloy health=up с последним скрейпом 10 мс назад, а remote_write рабочий
(node/postgres идут через него же и доезжают). Методом исключения — потери
в prometheus.relabel.cadvisor_trim, во втором правиле:
rule { source_labels = ["name"], regex = "", action = "drop" }
Замысел был выкинуть безымянные cgroup-ряды (id="/"). Но regex в Alloy
документированно дефолтится в (.*), и пустая строка неотличима от
незаданного значения — такой drop рискует выкидывать вообще всё, что и
наблюдалось. Заменено на однозначное keep regex=".+" — тот же замысел,
без зависимости от того, как трактуется пустой regex.
2. healthcheck alloy не мог пройти никогда: дёргал wget, которого в образе
grafana/alloy нет (как и curl, и nc). Контейнер вечно unhealthy при
полностью исправном alloy — ложная тревога, маскирующая настоящие сбои.
Заменено на сырой HTTP через bash /dev/tcp, без внешних утилит.
3. Prometheus раз в минуту писал "lookup alertmanager: no such host" и держал
up{job="alertmanager"}=0. Alertmanager намеренно за профилем alerts до
решения #3078 — дефект не в профиле, а в безусловной ссылке на сервис.
Оба места (alerting.alertmanagers и job_name: alertmanager) переведены на
file_sd_configs с файлом целей, по умолчанию пустым: целей нет — ошибок
тоже нет. Prometheus перечитывает file_sd на лету, поэтому включение
профиля сведётся к наполнению файла, без рестарта и правки конфига.
Файл целей смонтирован в сервис prometheus явным volume.
Проверено на живом хосте, не на глаз:
- alloy fmt обоих .alloy в одноразовом контейнере grafana/alloy:v1.6.1 - exit 0
- promtool check config в prom/prometheus:v3.1.0 - valid, 16 rules found
- механизм нового healthcheck выполнен внутри работающего gendesign-alloy:
первая строка ответа "HTTP/1.0 200 OK", grep матчится, RESULT=HEALTHY
- наличие bash/head/grep/printf в образе alloy подтверждено command -v
Владельцу понадобились креды Grafana (basic_auth Caddy + внутренний вход), а
они лежат в backend/.env.runtime, который check-secret-read.py закрывает
целиком. Снимать гард нельзя: в том же файле prod DB-пароли и токены
Forgejo/GlitchTip.
Вместо этого — ALLOWED_KEYS из трёх ключей (METRICS_UI_PASSWORD,
GRAFANA_ADMIN_USER, GRAFANA_ADMIN_PASSWORD) и разрешение ровно на anchored-греп
по ним. Условия намеренно жёсткие, чтобы «прочитать один ключ» нельзя было
развернуть в «выгрузить файл»: блокируются инверсия (-v / --invert-match),
пайпы, цепочки ; && ||, подстановки $(...) и обратные кавычки, редиректы.
Тест на 10 кейсов: разрешён только anchored-греп по ключу из списка; отбиты
инверсия, пайп, цепочка, редирект, подстановка, чужой ключ, греп без якоря ^ и
обычный cat. Проверяется и функция, и хук end-to-end через настоящий
stdin-payload.
Гард в деле: он же отбил эту самую команду коммита, когда текст сообщения
содержал имя закрытого файла рядом с read-verb.
Джоба agent-apps упала целиком:
error while interpolating services.postgres-exporter-infra.environment.
DATA_SOURCE_NAME: required variable INFRA_EXPORTER_DSN is missing a value
INFRA_EXPORTER_DSN нужен экспортеру с profiles: ["infra"], который на
продуктовом хосте не поднимается вовсе. Но compose интерполирует ВЕСЬ файл
до фильтрации по профилям, поэтому `${VAR:?}` роняет команду из-за чужой
переменной. Вместе с агентом не поднялись alloy, node-exporter и cadvisor,
которым никакой DSN не нужен. Симметрично упал бы и инфраструктурный агент -
на двух продуктовых переменных.
Второй дефект в той же цепочке: GENDESIGN_EXPORTER_DSN тоже отсутствовал.
setup-metrics-exporter-dsn.sh читает только runtime-файл окружения бэкенда, а
DATABASE_URL и TRADEIN_DATABASE_URL живут в основном. Значит подстановка
всегда была пустой, add_key печатал "нечем заполнить" и выходил с кодом 0 -
мягкий пропуск встречался с жёстким требованием compose.
Стало:
- compose: `:-` вместо `:?` у трёх DSN. Интерполяция больше не может упасть.
- deploy-metrics.yml: профиль экспортеров включается, только если нужные ЭТОЙ
роли DSN заполнены; иначе ::warning и агент поднимается без экспортера.
Громкость не убрана, а перенесена туда, где роль известна. Тот же приём, что
уже применён к Alertmanager в джобе server.
- setup-metrics-exporter-dsn.sh: читает оба файла окружения (базовый, затем
runtime - он перекрывает). Пишет по-прежнему только в runtime, лишних копий
пароля не заводит.
Почему `:-` не ослабление: пустой DATA_SOURCE_NAME поднял бы экспортер,
который молча не отдаёт метрик, - ровно тот тихий отказ, ради которого весь
стек и заводится. Поэтому пустой DSN теперь означает "профиль не включаем",
а не "поднимаем пустым".
Известное следствие, отмеченное в коде: INFRA_EXPORTER_DSN не собирает никто -
скрипт знает только про GENDESIGN_/TRADEIN_ и работает на продуктовом хосте.
Пока это так, инфраструктурный агент будет честно предупреждать, что метрик
Postgres инфры нет, вместо того чтобы падать целиком.
Тесты (3) структурные, проверяют оба конца инварианта: обязательности не
вернулись в compose; каждая DSN-переменная проверяется в деплое; профили не
захардкожены. Фальсификация: на исходных файлах краснеют все три.
Стек наблюдаемости не поднялся ни на одном хосте после мержа #3099: джоба
server упала, agent-apps и agent-infra пропустились как зависимые.
Причина (задача 23657, 26.08 08:55):
err: ОШИБКА: не нашёл роль с правом CREATE ROLE в gendesign-infra-postgres
setup-metrics-grafana-role.sh искал привилегированную роль перебором трёх
имён - glitchtip, forgejo, postgres. Ни одно не совпадает ни с одним реальным
кластером проекта: infra-postgres -> infra, gendesign-postgres-1 -> gendesign,
tradein-postgres -> tradein. Комментарий над перебором сам предупреждал, что
"угадывать postgres неверно", и дальше шло угадывание.
Замер на живом контейнере 26.08 (read-only, ничего не создавалось):
POSTGRES_USER изнутри контейнера: infra
glitchtip - отказ, forgejo - отказ, postgres - отказ, infra - 1
Стало: имя берём из POSTGRES_USER самого контейнера - это та переменная,
которой роль и создана при initdb, то есть источник истины. Прежний список
оставлен ПОСЛЕ него запасным путём для кластера не из образа postgres.
Попутно - глоб в paths деплоя. Воркфлоу запускает ТРИ setup-скрипта, а в
триггере стоял только setup-metrics-secrets.sh: правка двух остальных не
заводила выкат, и на хосте молча оставалась старая версия. Тот же класс, что
#2203 закрыл глобом ops/*.sh. Добавлен тест, который сверяет запускаемые
скрипты с шаблонами paths - на исходном воркфлоу он краснеет, указывая на
setup-metrics-exporter-dsn.sh.
Тесты (6) исполняют РЕАЛЬНЫЙ скрипт с подставным docker и проверяют
фактический выбор роли, а не наличие правильных слов в комментарии.
Фальсификация: на исходном коде краснеют 3 из 5 ролевых тестов; проходят
только те два, что фиксируют сохранённое поведение (запасной перебор и
громкая ошибка при отсутствии привилегий). tests/ops целиком - 28 passed.
Follow-up к #3103. Прод лёг на ~30 минут потому, что PR завёл
`import ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не добавил
bind-монты этих файлов в docker-compose.prod.yml.
Соседний гард `caddy validate` эту дыру не ловит принципиально: он копирует
каталог caddy/ целиком (`docker cp caddy ...`), а на проде смонтированы только
отдельные файлы плюс два каталога. Расхождение между «что лежит в репозитории»
и «что реально видит контейнер» видно только если сверять с маунтами.
check-caddy-snippet-mounts.py разбирает bind-монты сервиса caddy:, резолвит
каждый `import` в Caddyfile / caddy/sites/*.caddy / caddy/*.caddy.snippet
относительно КОНТЕЙНЕРНОГО пути импортирующего файла и падает, если цель не
покрыта ни одним маунтом. Именованные сниппеты `(name) { }` пропускаются,
для glob/placeholder-импортов (`caddy/sites/{$CADDY_SITES:*}.caddy`)
проверяется каталог. --selftest воспроизводит ровно баг #3102.
PR #3102 добавил caddy/metrics-ingest.caddy.snippet и metrics-ui.caddy.snippet
плюс `import ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не
добавил bind-монты в docker-compose.prod.yml. Каталог caddy/ внутрь контейнера
целиком не пробрасывается — только пофайлово (как users.caddy.snippet) плюс
каталоги caddy/local и caddy/sites. Файлы лежали на хосте, но внутри
контейнера их не было.
Итог на проде 2026-08-26 ~11:26 MSK: Caddy не смог адаптировать конфиг
(`File to import not found: ../metrics-ingest.caddy.snippet, at
/etc/caddy/caddy/sites/infra.caddy:87`) и ушёл в restart-loop. Легли ВСЕ
сайты хоста — gendsgn.ru, merahome.ru, обе mera-витрины, а не только
metrics.gendsgn.ru, ради которого сниппет и добавляли.
Монтируем оба файла явно, рядом с users.caddy.snippet.
Третья часть #3078 и единственная, трогающая прод-код.
До неё числовых рядов у приложений не было вовсе: только логи и исключения в
GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой
картине невидим — исключения нет, строка в логе выглядит обычной, а продукт
при этом не работает.
Метка route — ШАБЛОН маршрута, а не путь запроса. Это несущее решение, а не
деталь: кадастровый номер или идентификатор заявки в метке даёт новый временной
ряд на каждую сущность, а ряд у Prometheus стоит памяти постоянно, а не в момент
запроса. Самый известный способ уронить мониторинг тем самым мониторингом.
Незаматченные пути (404, сканеры) сведены в одну метку, иначе тот же взрыв
устроит любой бот, перебирающий адреса. Оба свойства сторожатся тестами, а не
комментарием: тест бьёт тремя разными идентификаторами и требует ОДИН ряд.
Слой регистрируется последним и потому оказывается самым внешним. Изнутри
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим
I/O в middleware (#1202). Упавший исключением запрос считается как 500 в
finally: без этого он просто отсутствовал бы в счётчике, то есть ровно тогда,
когда метрики нужнее всего.
Путь публичен ВНУТРИ и закрыт СНАРУЖИ — это два разных периметра. Скрейп идёт
из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому
/metrics внесён в _PUBLIC_PATHS обоих бэкендов; иначе агент получал бы 401 и
метрик не было бы вовсе. Наружу путь не открывается ни через gendsgn.ru, ни
через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках —
чтобы закрытость осталась решением, а не следствием текущего порядка директив.
Ограничитель частоты и аудит «Меры» не трогались: оба смотрят только на пути
под /api/, скрейп под них не попадает. Проверено тестом, а не чтением.
Прод-поведение не меняется ничем, кроме нового публичного пути: ни один
существующий обработчик, гвард или маршрут не тронут.
Refs #3078
Вторая часть #3078. Конфигурация экспортеров приехала первым коммитом, но
смотреть на неё было негде: без витрины ряды есть, а ответа на вопрос нет.
Панели подобраны по разборам постфактум, а не по списку «что обычно рисуют».
Доля апдейтов мимо HOT — потому что у listings она была 0,43 % при 198
апдейтах на строку, и именно это дало 15 ГБ TOAST при 230 МБ живого
содержимого (#2992/#2989), копившиеся 91 день. Возраст самой старой
транзакции — потому что осиротевшие запросы висели 46 часов и держали
горизонт видимости, из-за чего autovacuum не убирал мёртвые строки во всей
базе (#2607). WAL за сутки — потому что 7,02 ГБ при четырёх пользовательских
расчётах это диспропорция, заметная только на ряде. Размер баз — потому что
у GlitchTip нет политики ретенции вовсе, и он растёт без ограничения.
Размеры разложены на heap / индексы / TOAST: суммарный размер таблицы не
объясняет ничего, а именно это разделение объяснило, куда ушли 19 ГБ.
Отдельная панель «экспортер отвечает»: пустой график и упавшая база выглядят
одинаково, и различать их должно что-то явное.
Refs #3078
Запрос оценки один и блокирующий (POST /estimate, mutation.isPending) —
промежуточных событий по источникам не существует, а карточка рисовала
пофайловые «сбор...» с полосками 60%, счётчик 0/5 и подпись про Celery
group с таймаутом. Пользователь не мог отличить «думает» от «завис», а
серверная механика текла в UI-текст.
Вариант A из задачи (только фронт):
- на pending строки нейтральны («ожидает ответ»), раскраска — только по
факту estimate.sources_used после ответа;
- общая полоса на pending — честная неопределённая анимация вместо
выдуманного процента (Math.min(95, ...) убран);
- счётчик N/M на pending заменён на «опрашиваем…» (0/5 при идущей работе
читался как отказ);
- подпись без Celery/таймаута, тон legacy-заглушки «Считаем оценку…»;
- недостижимые ветки error/loading у строк удалены (их ничто не выставляло).
Вариант B (реальный статус с бэкенда) отклонён сознательно: /estimate
остаётся синхронным по решениям #3082/#3083, городить async+task_id ради
прогресс-бара — против них.
Preview-страница /ui-preview/estimate теперь рендерит ОБА состояния
карточки. Проверено скриншотом на dev (pending + done).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detail-обогащение извлекало agency_name, но признак «продаёт профи» не
выводило — 4 469 активных листингов с известным агентством стояли с
is_pro_seller=NULL (признак эрозирован SERP-затиранием до PR #3067, а
заново не появлялся). Признак идёт в оценщик trade-in.
- save_detail_enrichment: is_pro_seller=TRUE при известном agency_name,
fill-only COALESCE; отсутствие блока НЕ доказывает «частник» (п.3 задачи —
отдельное решение), в ту сторону ничего не пишем;
- миграция 271: одноразовый бэкфилл уже существующих строк всех источников
(правило источник-независимо), идемпотентна.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Метрик в проекте не было ни одной: ни экспортеров, ни /metrics в бэкендах,
единственный канал наблюдения — journald, единственный сигнал об аварии —
исключение в GlitchTip. Из-за этого целый класс отказов невидим в принципе:
задача рапортует done, строк ноль, исключения нет. Так протухли данные на семь
месяцев (#2998), 34 дня был мёртв house_imv_backfill (#2698), 8 суток писал ноль
newbuilding_enrich (#2767), 91 день копилось раздутие listings (#2992).
Grafana не заменяет GlitchTip: ошибки остаются там. Grafana OSS не принимает
Sentry DSN ни одним компонентом, а скрубберы в before_send — требование 152-ФЗ.
Здесь появляется другой класс данных: ряды и алерты по трендам.
Наблюдатель поставлен у ДРУГОГО провайдера, чем наблюдаемое: серверная сторона
на Beget, рядом с GlitchTip. Если ляжет Poincare, мониторинг должен об этом
сказать, а не лечь вместе с ним.
Транспорт push, а не pull: агент на Poincare шлёт remote_write и логи исходящим
HTTPS, поэтому там не открывается ни одного входящего порта сверх 22/80/443.
При обрыве канала Alloy копит в WAL и досылает — pull-скрейп в той же ситуации
терял бы точки именно в аварии, ради которой мониторинг и нужен.
Два контура доступа с разными учётками. Пароль приёмника по построению лежит
открытым на продуктовом хосте, значит его компрометация неизбежна вместе с
хостом; будь это учётка витрины, утёк бы и доступ к дашбордам.
GlitchTip читается прямым SQL, а не Sentry-плагином: у плагина на 6.1.6
stats_v2 отдаёт 500 (баг GlitchTip #381), Events/Discover — 404 (#416), а в
grafana/sentry-datasource слово glitchtip не встречается ни разу. Схема сверена
на живой базе: колонка времени называется timestamp, а не received, и отдельной
таблицы IssueIndex не существует — агрегаты лежат на самой issue_events_issue.
Алерты за профилем alerts: канал доставки — открытый вопрос #3078, и стек не
должен на нём стоять. Деплой предупреждает, что уведомлять пока некому.
Каждая настройка, способная отказать молча, закрыта явно: ретенция Prometheus
задана и по времени и по размеру, retention_enabled у компактора Loki (без него
retention_period не работает вовсе), путь к журналу и запуск Alloy от root
(иначе агент читает ноль записей без ошибки), проверка Caddy до перезагрузки
(на этом хосте тот же Caddy держит git, errors и obsidian).
Refs #3078
Из таблицы убитых деплоем: yandex_city_sweep 15.08 прожил 65 мин, 12.08 —
2ч33м; оба потеряны целиком — у свипа не было чекпоинтов вовсе.
Единица чекпоинта — combo (сегмент × комнатность × ценовой диапазон), ровно
то, чем цикл обхода уже итерируется. Три слоя:
- провайдер: skip_combos (ни одного HTTP по собранным) + on_combo для каждого
ПРОЙДЕННОГО combo, включая пустые — иначе пустой combo не попадал бы в
чекпоинт и перечитывался бы вечно; оборванный отказом combo (gate failure)
on_combo по-прежнему не вызывает;
- пайплайн: done_combos → heartbeat с done_buckets (мерж jsonb, финализаторы
не затирают); подхват гейтится единственным якорем — combo_label не содержит
якоря, multi-anchor подхват пропускал бы чужие якоря;
- планировщик: resume_run_id=_pick_resume(...) в диспатче (generic-механизм
#2845 — params-идентичность, свежесть точки, потолок цепочки — бесплатно).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт: прогон 4707 (23.08) подхватил 42 корзины у 4117, был убит деплоем
на 26-й минуте до завершения первой НОВОЙ корзины — и не успел ни разу
написать heartbeat с done_buckets. Его собственный чекпоинт пуст: следующий
кандидат увидел бы no_checkpoint, цепочка оборвалась бы с потерей 42 корзин.
_resume_decision при вердикте 'ok' теперь кладёт done_buckets предшественника
в counters-заготовку нового прогона — она персистится при claim, до старта
пайплайна. Heartbeat мержит jsonb: первый настоящий bucket-heartbeat
перезапишет ключ надмножеством, двойной записи нет. Отказные вердикты
чекпоинт не наследуют (прогон с нуля не должен врать о собранном).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Рейт-лимит меряет частоту (300/60с вправе стартовать в одну секунду), квота —
счётная и помесячная: параллелизм /estimate не ограничивал никто. Оценка
0.8–2.4с держит соединение общего пула SQLAlchemy (5+10) и внешние тиры —
пила одновременных оценок выедала пул и тормозила весь /api/v1/*.
Семафор по образцу public/mera.py::_suggest_slots: acquire после дешёвых
отказов (рейт-лимит, квота) с ожиданием 5с ≈ две длительности оценки,
timeout → 429 с Retry-After; release в finally сразу после дорогой части.
4+4 слота (estimate+suggest) = 8 удерживаемых соединений из 15 пула.
Семафор в памяти процесса — при переходе на несколько воркеров (#3083)
лимит умножится на их число; задачи согласовывать (о чём комментарий на месте).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт: замер по avito_detail_backfill прочитал «обогащено 0 за 7 дней»
при реальных 801 — джобы пишут результат только ключом 'enriched', которого
_column_counts не знал, и колонка total_seen оставалась 0 у всех прогонов.
'enriched'-фолбэк добавлен именно в _column_counts (витринная колонка), а НЕ в
_RESULT_COUNTER_KEYS: тот список кормит zero-result-стрик, где догнавший
очередь бэкфилл стал бы непрерываемым измеренным нулём — ловушка, за которую
ревью уже выкинуло из списка 'rows_inserted' (#2703). Контракт стрик-сторожа
закреплён инвариант-тестом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Путь Selectel-Telegram теряет соединения. Замер 26.08 с Poincare, 40
подключений к закреплённому (#3093) 149.154.167.220:
успешно 37 из 40, отказов 3 (7.5%) - все TimeoutError
время успешных: min 0.14s, медиана 0.15s, max 0.17s
Отказы происходят на стадии ПОДКЛЮЧЕНИЯ - быстрые стабильные успехи на фоне
редких таймаутов. Три остальных дата-центра Telegram с Selectel недостижимы
вовсе, так что закрепление адреса потери убрать не может: запасного адреса
нет. Бот это переживает своими ретраями (106 таймаутов за сутки, 97 лечатся
первой же повторной попыткой), а вот алерты - нет.
uptime-healthcheck.sh: был один curl и `|| log WARN` - каждый отказ терял
уведомление целиком. Сторож, который не может дозваться, - худший вид
самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс, что о них
не сообщат. Ирония в том, что ниже в этом же файле HTTP-проверки уже
повторяются циклом: ретраили то, что измеряют, но не то, чем докладывают.
lib-backup.sh: тоже один curl, но с падением в почту (#3070). Алерт не
терялся, зато каждый транзиентный таймаут впустую сжигал последнее средство
вместо простого переподключения.
Стало: цикл из трёх попыток в обеих notify(). Не `curl --retry` - семантика
--max-time при ретраях зависит от версии curl, а цикл даёт таймаут на КАЖДУЮ
попытку и повторяет идиому, уже принятую в uptime-healthcheck.sh.
Дубль вместо потери - осознанный размен: sendMessage не идемпотентен, но
отказ случается ДО отправки запроса, так что повтор почти никогда не
продублирует доставленное. Лишний алерт безвреден, пропущенный - нет.
Тесты (backend/tests/ops/test_3059_alert_retry.py, 7 шт) исполняют РЕАЛЬНЫЕ
notify(), извлечённые из обоих скриптов, с подставным curl, отказывающим
заданное число раз, и считают фактическое число попыток.
Фальсификация: на исходных скриптах краснеют 6 из 7. Проходит только
test_backup_falls_back_to_mail_when_telegram_is_really_down - он фиксирует
сохранённое поведение, а не регресс.
Проверено: `bash -n` обоих скриптов (та же проверка, что в CI - shellcheck
там нет); конструкция `[[ ]] && cmd` в конце тела цикла безопасна под
`set -euo pipefail`, который стоит в uptime-healthcheck.sh:25 (проверено
исполнением, не рассуждением); tests/ops целиком - 22 passed.
Два разведчика без потолка ушли на 157 и 178 ходов вместо инвентаризации:
один вместо списка веток диффал SQL-миграции и разбирал Caddyfile. Третий
собрал 17 739 байт в один StructuredOutput, получил InputValidationError и
потерял всю работу целиком.
Канон уже говорил «~≤20 tool-calls» и «границы: что НЕ делать» — но как
ориентир для оркестратора. Агент этого не видит: лимит, оставшийся в голове
вызывающего, не ограничивает никого. Отсюда правка — бюджет обязан быть
текстом внутри промпта, вместе со списком запрещённого и правилом деградации
«отдай что есть, допиши в notes что не успел».
Отдельным пунктом — потолок РАЗМЕРА структурированного ответа. Он не следует
из лимита токенов: payload больше ~10k символов не парсится, повтор, и работа
теряется. Схему надо проектировать под краткость, длинные detail-поля
провоцируют ровно этот отказ.
Новая секция «Целость результата workflow»: завершившийся прогон не значит
успешный — упавшие агенты возвращают null, parallel() их молча проглатывает,
а синтез всё равно пишет уверенный текст с числами. Плюс восстановление через
resumeFromRunId, диагностика зависшего агента по возрасту записи в
agent-*.jsonl, и грабля Windows: перезапись скрипта через python даёт CRLF,
запуск отбивается на control characters.
Замер 2026-08-23 с Poincare: у api.telegram.org семь публикуемых адресов,
отвечает РОВНО ОДИН — 149.154.167.220 (302 за 0.11 с). Резолвер при этом
отдаёт 149.154.166.110, который мёртв. Скан всего 149.154.167.0/24
подтвердил: живой он один во всём блоке.
Это не блокировка Selectel и не наш файрвол: с Beget отвечают три адреса
из тех же семи, включая тот, что отдаёт DNS там. Telegram частично
фильтруется у ОБОИХ провайдеров, просто Beget попадает на живой адрес.
Через прокси ASocks Telegram не проходит ни с одного из четырёх узлов
(все 000) — узлы российские, путь закрыт.
Без закрепления tradein-tgbot на long-polling резолвит мёртвый адрес,
виснет и умирает МОЛЧА: restart поднимает его заново, он снова виснет,
ни краша, ни строки в логе.
Закреплены все три потребителя Telegram, а не только бот:
- tgbot и backend — extra_hosts в новом docker-compose.selectel.yml.
backend тоже ходит в Telegram: пересылка алертов GlitchTip
(api/v1/glitchtip.py) и support-чат (api/v1/support.py).
- хостовые скрипты ops/lib-backup.sh и ops/uptime-healthcheck.sh —
шаг 11 в selectel-bootstrap.sh кладёт запись в /etc/hosts.
Им compose не помогает, они идут из cron, не из контейнера.
Отдельный override-файл, а не правка docker-compose.prod.yml: на Beget
закрепление не нужно, и менять поведение действующего прода ради
будущего хоста нельзя. Файл просто не передаётся в -f.
Адрес вынесен в TELEGRAM_API_IP с текущим дефолтом — если он умрёт,
правка в одну переменную окружения без релиза. Шаг bootstrap проверяет
не факт записи, а что Bot API отвечает 401 на фиктивный токен, и при
другом коде печатает команду поиска нового живого адреса.
Проверено: docker compose config даёт ровно две вставки extra_hosts
(tradein-backend, tradein-tgbot) и ничего больше; TELEGRAM_API_IP
подставляется в обе.
Refs #3059, #3057
Репетиция полного перелива на Poincare (12 потоков / 62 ГиБ / NVMe RAID1)
2026-08-23 вскрыла две мины окна 30.08, которых нет ни в одном issue.
1. mem_limit: 3g зашит жёстко. На 62 ГиБ целевое 44g, и лимит КОНТЕЙНЕРА
обязан подниматься РАНЬШЕ shared_buffers — он срабатывает раньше
postgresql.conf, поэтому shared_buffers=12GB при mem_limit=3g убивает
контейнер OOM-kill'ом на старте. Предупреждение об этом в файле было,
правки не было.
2. Оба compose монтируют каталог миграций в /docker-entrypoint-initdb.d.
На СВЕЖЕМ томе цепочка из 249 миграций отработает ДО восстановления
дампа и засеет данные (scrape_schedules 157 строк, tradein_users 13,
deals 80), а дамп идёт с --clean --if-exists.
Параметризованы только те значения, что зависят от размера хоста:
TRADEIN_PG_MEM_LIMIT (одна переменная на mem_limit и memswap_limit —
инвариант «без свапа» обязан держаться и после правки), TRADEIN_PG_SHM_SIZE,
shared_buffers, effective_cache_size, work_mem, maintenance_work_mem,
max_wal_size, плюс TRADEIN_PG_INITDB_DIR / GENDESIGN_PG_INITDB_DIR.
checkpoint_timeout, wal_compression, random_page_cost, pg_stat_statements
от размера хоста не зависят — не тронуты.
Все дефолты равны текущим прод-значениям на Beget. Проверено docker compose
config дважды на обоих файлах: без переменных резолвится в 3g / 512m /
768MB / 6GB / 16MB / 256MB / 4GB и штатные пути initdb — бит-в-бит как
в main; с переменными — в 44g / 12GB / 36GB / 64MB / 2GB / 16GB.
Лимиты остальных сервисов не изменились.
Postgres Site Finder (корневой compose) намеренно получил только переменную
initdb: mem_limit и тюнинга у него сейчас нет вовсе, добавление изменило бы
поведение текущего прода. Он до сих пор на стоковом конфиге —
shared_buffers 128 МБ на базу 15 ГБ, wal_compression off — отдельная задача.
Refs #2989, #3057
Правка меняет две вещи разом, обе намеренно.
1. Равенство по дате -> «последняя строка не позже якоря».
listing_source_snapshots переходит на модель «строка на изменение».
В ней equality-join теряет 95.6% пар (на 2026-08-20 изменениями являются
4458 строк из 101795): выборка для percentile_disc схлопывается до n=3-4,
квантиль вырождается в максимум из трёх чисел, TTL обваливается —
domklik 28->14 (под снятие сразу 128 активных строк), yandex 44->30,
avito 13->10.
Дыры в суточной истории ломали equality-join и до перехода: на проде
03-14.06 (12 суток подряд), 03-04.07, 12.07, 26.07, 30-31.07, 01.08 —
там n_pairs=0, floor_days=NULL и TTL молча оставался как задан.
2. Глобальный max(snapshot_date) -> максимум внутри источника.
Старый подзапрос брал максимум по ВСЕЙ таблице, не скоупленный по
listing_source_id: источник, чья история короче общей, выпадал из
выборки целиком. LATERAL ищет предшественника по строке.
Замер на живом проде 2026-08-23 (health_window_days=3): обе формы дают
побитово одинаковый результат на всех четырёх источниках —
avito n=765 пол=49.7, cian n=1226 пол=81.6, domklik n=565 пол=35.7,
yandex n=3856 пол=87.3. Сегодня суточная джоба пишет строку для каждого
источника каждый день, поэтому глобальный максимум совпадает с максимумом
каждого источника, и пункт 2 — no-op на текущих данных. Расхождение
проявится только на дырах и после перехода на change-only.
Плюс floor_n_pairs в counters — наблюдательность для будущего гейта
деградации пола, сейчас ничего не блокирует.
FROM..WHERE вынесен в _revisit_floor_from_where_sql, чтобы count(*) и
percentile_disc гарантированно шли по одному срезу.
Замер на проде 2026-08-22, разбивка одного расчёта по логам:
10.659 старт
10.827 дом найден 0.17 с
11.565 аналоги, 49 кандидатов 0.74 с
11.579 ДКП-коридор 0.01 с
17.576 yandex_valuation ← 6.0 с
19.159 cian_valuation ← 1.5 с
19.252 готово итого 8.6 с
Семь с половиной секунд из восьми с половиной — ожидание чужих HTTP. Наша база
и сам расчёт укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с,
по новому — 8-12.4 с. Геокодинг ни при чём: с готовыми координатами те же 7.5-10.8.
Публикация в РБК 30.08 приведёт аудиторию на НОВЫЕ адреса, то есть мимо кэша.
Масштабирование контейнеров тут не помогает: время уходит на ожидание чужого
ответа, а не на наши вычисления.
Что сделано: у обоих источников появился режим «только кэш» (fetch_on_miss).
При включённом ESTIMATE_EXTERNAL_SOURCES_BACKGROUND запрос делает лишь чтение
кэша (локальный запрос на миллисекунды), а свежая загрузка уходит в фон и
наполняет кэш к следующему обращению по тому же адресу.
Почему это безопасно: деградация источника в None — НЕ новое состояние ответа.
Ровно так же ведёт себя таймаут estimate_*_valuation_timeout_s, и этот путь
работает в проде сегодня. Контракт API не меняется.
Фоновая задача берёт СВОЮ сессию: сессия запроса закрывается вместе с ответом,
а обе функции источников делают внутри себя db.commit() — переиспользование
чужой сессии зафиксировало бы её незавершённую работу. По той же причине
отвергнут наивный asyncio.gather двух источников на одной сессии.
Очередь догрузки ограничена восемью задачами. Без потолка всплеск по новым
адресам — ровно тот случай, ради которого режим и сделан — породил бы сотни
параллельных задач с сессиями и HTTP-клиентами при max_connections 100 и
mem_limit 768m у backend, то есть отказ вместо ускорения.
Дефолт в коде False: поведение других окружений не меняется. На проде режим
включён через docker-compose.prod.yml у сервиса backend.
Отдельно НЕ сделано, хотя предлагалось: снижение таймаутов до 4 с. Замер
показал, что свежий запрос к Яндексу занимает 6 с — таймаут 4 обрывал бы его
почти всегда, кэш бы не наполнялся, и источник оказался бы тихо отключён.
Таймаут здесь страховка от патологии, а не регулятор задержки.
Тесты: 7 штук на режим «только кэш», собственную сессию, гашение ошибок,
удержание ссылки на задачу и потолок очереди. Фальсифицированы — на неизменённом
коде падают 5 из 7 (проходит только сторож неизменности дефолта). Смежные
тесты оценщика (29 штук: бюджет ЦИАН, клиентские координаты, аудит) зелёные.
Замер 2026-08-22, уже после починки транспорта (#3049): обогащение работает,
но очередь не разбирается. За сутки 238 карточек при 9 951 активном объявлении.
Причина в планировщике, а не в скрапинге. `compute_next_run_at` имеет суточную
гранулярность по построению — `interval_days = max(1, int(...))`, целевая дата
`now + interval_days`. Меньше суток не выражается. Бэкфилл при этом умирает по
бану через 17-83 минуты, то есть работал около часа в сутки, а остальное время
расписание ждало следующего дня.
Механизм sub-hourly каденса уже был написан — `reschedule_after_minutes`
(#2162, сделан для proxy_healthcheck). Его просто не подключили к бэкфиллу.
Хук ставит next_run_at = now() + interval_minutes сразу после claim и сам себя
тормозит: пока прогон идёт, has_running_run в _claim_run возвращает None.
180 минут — осознанно консервативная отправная точка, НЕ найденный оптимум.
Данных для подбора нет, и имеющиеся два прогона противоречат наивному
ожиданию: 4562 дал 175 карточек за 83 минуты, а 4586 через 4.3 часа — когда
пул прокси был давно чист — умер за 17 минут с 42 карточками. Значит память
Авито длиннее часов, и учащение может ухудшить выход.
Отдельно держать в голове при подборе: те же 4 прокси обслуживают SERP-свипы,
то есть первичный сбор. Сжечь их на обогащении хуже, чем медленно обогащать.
Двигать интервал вниз только по замеру нескольких суток, глядя и на свипы.
Подбор — через default_params расписания, правка кода для этого не нужна.
Тесты закрепляют подключённость хука и коридор дефолта, а не конкретное
значение: 180 будет двигаться, а вот утрата хука вернёт суточный простой
молча. Проверено фальсификацией — на неизменённом коде все три падают.
Найдено при первом живом запуске на Poincare.
Ubuntu кладёт в /etc/ssh/sshd_config.d/ файл 50-cloud-init.conf с
PasswordAuthentication yes. sshd берёт ПЕРВОЕ встреченное значение директивы,
а не последнее, поэтому наш 60-hardening.conf проигрывал по имени: после
reload парольный вход для root остался бы открытым.
Коварство в том, что `sshd -t` при этом отвечает «конфиг валиден», и скрипт
рапортовал об успехе. Сервер уже под перебором (fail2ban держал 6 адресов),
так что тихий отказ здесь стоил бы дорого.
Три правки:
- файл называется 00-hardening.conf и читается первым; старый 60-* удаляется,
чтобы повторный запуск не оставил два конфликтующих;
- после записи проверяется не синтаксис, а ИТОГ через `sshd -T`: если
passwordauthentication != no, скрипт падает и НЕ перезапускает sshd;
- комментарий про версии Docker приведён в соответствие с поведением —
ставится последняя из репозитория, версии прода служат ориентиром для
предупреждения (приехали 29.7.2 / 5.5.0 против 29.4.1 / v5.1.3).
Сервер Poincare (SPB-4, 188.246.224.93) поднят 22.08.2026. Два скрипта под
переезд (#2989, #3027-#3032).
selectel-audit.sh — только чтение. Отвечает на вопрос, который надо закрыть
до установки чего бы то ни было: собраны ли диски в зеркало. Переставить ОС
можно только пока сервер пустой.
Результат первого прогона: RAID1 на всех трёх разделах ([2/2] [UU] на /boot,
swap и /), память non-ECC подтверждена (Error Correction Type: None),
910G свободно из 924G.
selectel-bootstrap.sh — идемпотентная настройка: пользователь с ключом,
ufw, Docker версий текущего прода (29.4.1 / v5.1.3), лимит журналов
контейнеров, sysctl, unattended-upgrades, fail2ban, ужесточение sshd.
Порядок шагов выбран так, чтобы не потерять доступ: правило для SSH
добавляется ДО включения файрвола, а sshd в конце намеренно НЕ
перезапускается — сначала проверяется вход по ключу в отдельном окне.
При выключенном PasswordAuthentication ошибка означала бы IP-KVM за 1320 руб.
Два умолчания выставлены по факту, а не по привычке:
- часовой пояс Europe/Moscow, а не UTC — оба хоста сейчас на MSK, и смена
пояса сдвинула бы все шесть cron-задач (бэкапы, геокодинг) на три часа;
- swap не создаётся, если он уже есть — установщик Selectel отдаёт раздел
4.7G в зеркале, добавлять файл поверх незачем при 62G памяти.
.gitattributes: *.sh с LF — иначе скрипт, отредактированный под Windows,
падает на удалённом хосте с `$'\r': command not found`.
Замер на проде 2026-08-21: Авито за QRATOR отдаёт JavaScript proof-of-work
челлендж (startPow → кука pow_solved → self-reload через window.location).
curl_cffi не исполняет JS и пройти его не может в принципе.
Что это давало в цифрах (counters прогонов avito_detail_backfill):
run 4508: enriched 3 / attempted 46
run 4394: enriched 2 / attempted 47
run 4348: enriched 4 / attempted 53
~6% успеха. Браузерный путь на том же проде — ~74% (17/23 в инлайн-обогащении
свипа, 2/3 в точечной проверке после #3048). То есть задание работало почти
вхолостую, а поля дома, которые чинил #3048, до карточек просто не доходили.
Исходное обоснование AVITO_DETAIL_BACKFILL_USE_CURL=true (browser открывает
десятки коннектов и превышает cap прокси-аккаунта auv) больше не действует:
браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect
SCRAPER_PROXY_URL, что и curl-путь.
Переключение сделано в docker-compose.prod.yml (environment перекрывает
env_file), а не правкой рантайм-env на хосте — чтобы изменение осталось в
репозитории и пережило deploy без дрейфа конфигурации. Дефолт в config.py
оставлен True: другие окружения не трогаем вслепую, но устаревшее обоснование
там помечено.
Воркер сослался на #3086, которого не существует — тест с таким именем
остался бы загадкой для следующего читателя. Заведён настоящий issue #3047
с замерами покрытия и разбором дефекта; все ссылки и имя файла приведены
к нему.
Refs #3047
Разобрались по эталонной разметке (2 живые detail-карточки, 2026-08-21), что реально
парсится и что нет:
- sale_type: item-view/item-params — Avito отдаёт "О квартире" и "О доме" как ДВА
ОТДЕЛЬНЫХ <div> с ОДНИМ И ТЕМ ЖЕ маркером, а не один div с двумя <ul>, как
предполагал старый код. css_first брал только ПЕРВЫЙ div — sale_type это не
задевало (он в первом блоке), но house_type/total_floors_house/лифты из второго
блока терялись молча через мёртвую ветку `len(ul_els) >= 2`. Теперь читаем <ul>
из ВСЕХ блоков с этим маркером — устойчиво к порядку блоков.
- metro_stations: раньше только эвристика по тексту описания (28 строк из 54 855).
Основной источник теперь — структурная разметка (#item-view-address, иконка
"Пешком до метро"), покрывает станции без ограничения на суффикс имени
(METRO_RE ловил только "-ская"/"-инская"). Описание — фолбэк.
- is_homeowner: не парсился вовсе. [data-marker='seller-info/label'] — "Частное
лицо" -> True, "Агентство" -> False. Подтверждено разными значениями на двух
эталонах.
- days_on_market: на странице явно нет, но есть publish_date, из которого честно
выводится. Заодно чинит сам publish_date — искали дату ВНУТРИ item-id-блока, а
она в СОСЕДНЕМ [data-marker='item-view/item-date'] ("сегодня в HH:MM").
- cadastral_number: подтверждено отсутствие на странице (не парсим, не выдумываем).
Тесты на реальной разметке (backend/tests/test_avito_detail_fields_3086.py) на
обеих эталонных фикстурах (большие embedded JS-блобы вырезаны из фикстур —
не используются DOM-based парсером, экономят место). estimator.py не тронут —
ни одно поле не влияет на цену.
Найдено при ревью ветки. `_wait_out_pow_challenge` опрашивал `page.content()`
без защиты, а челлендж перезагружает страницу САМ (`window.location =
location.href`). Вызов content(), попавший в момент этой перезагрузки, кидает
«Execution context was destroyed, most likely because of a navigation» — то
есть цикл ронял фетч ровно тогда, когда проверка успешно пройдена и мы
дождались того, ради чего ждали.
На моках дефект не воспроизводился: поддельная page навигацию не рвёт.
Добавлено:
- `_content_during_navigation()` — content(), возвращающий None вместо
исключения, если текст ошибки указывает на гонку с навигацией. Различаем
по тексту, а не по типу: сервис не импортирует playwright, page приходит
готовым. Всё прочее (закрытая страница, упавший браузер) пробрасывается.
- Цикл трактует None как «ещё не устоялось, опроси снова».
- Финальная догидрация тоже защищена: один короткий добор, затем внятная
ошибка вместо падения на гонке.
- Поддельная page в тестах умеет поднимать исключение из content();
три теста на гонку — прохождение, вечная навигация, посторонняя ошибка.
Фальсификация: без правки два новых теста падают именно с
`Execution context was destroyed`. Полный сьют сервиса — 123 passed.
Refs #3045
Живой замер 2026-08-21 (#3045): фиксированной паузы BROWSER_WAIT_MS (6с) не
хватает на цепочку «PoW-расчёт в JS → таймер 3с → self-reload → гидрация» —
4 из 6 карточек с органической навигацией отдавали 7891-байтную challenge-
страницу вместо контента (не бан, "проверка безопасности"). caller считал её
валидным HTML — парсер либо падал, либо молча ничего не находил.
_fetch_once теперь опрашивает page.content() (шаг ~1с, бюджет
BROWSER_CHALLENGE_WAIT_MS=30000) пока маркеры челленджа (startPow / "проверка
безопасности") не исчезнут, затем догидрируется тем же BROWSER_WAIT_MS. По
истечении бюджета — ChallengeTimeoutError вместо заглушки. wait_for_url не
годится: страница перезагружает саму себя, URL не меняется.
Бан-страница ("проблема с IP") распознаётся отдельно и падает сразу
(BanPageDetectedError), без траты бюджета ожидания — это не то же самое, что
челлендж, и ждать там нечего.
Провайдер-агностично по форме: включается только по факту маркеров в HTML,
cian/yandex/generic их никогда не отдают.
Замер живьём 2026-08-21 через tradein-browser (camoufox, JS исполняется):
59-60 уникальных `data-item-id` на странице выдачи. «Лишние» сверх 50 —
обычные объявления с платным продвижением (`vas-icon_type-promoted`), они
лежат в том же списке под `page-title/count`, а не отдельным рекламным
блоком, и собираются наравне с остальными.
Направление эффекта важно понимать правильно. Константа участвует ТОЛЬКО
в `ceil(total / PAGE)`, поэтому занижение размера страницы ЗАВЫШАЛО
расчётное число страниц, а не занижало:
- запрашивали примерно на 17 % страниц больше, чем нужно; при доле банов
37-83 % по avito-заданиям лишние запросы — основная цена ошибки;
- `tail_loss` считался как `total - cap * 50` и завышал потерю;
- флаг `complete` в пагинации листа чаще ложно показывал «неполно».
Тихой потери данных НЕ было: условия «страница вернула меньше PAGE
карточек, значит последняя» в коде нет, пагинация ограничена только
`max_pages`. Тест закрепляет и значение, и направление арифметики, чтобы
неверная трактовка не вернулась при следующем рефакторинге.
Найдено при разборе Авито сверкой живого браузера со скраппером; полный
разбор — в волте `research/Avito_Live_Browser_Recon_0821.md`.
Refs #3033
DEFAULT_IMPERSONATE переведён на chrome146, но оба pyproject продолжали
объявлять `curl-cffi>=0.7.0`. В 0.7.0 такого профиля нет: установка по
нижней границе — и curl_cffi роняет КАЖДЫЙ запрос скраппера на невалидном
impersonate, причём одинаково для avito, cian и yandex.
На проде стоит 0.15.0 (проверено в контейнере tradein-scraper: профили
chrome99 … chrome146 плюс алиас chrome), поэтому текущий деплой бы не
пострадал — но любая пересборка окружения с резолвом к нижней границе
воспроизвела бы отказ, и диагностировался бы он как «скрапперы легли»,
а не как несогласованная зависимость.
Refs #3034
Живой замер браузера 2026-08-21 разошёлся с тем, что шлёт прогретая сессия:
- impersonate="chrome120" был захардкожен в 9+ местах (avito/{serp,detail,imv,
houses}.py, pipeline.py x4, cian/valuation.py, yandex/valuation.py) вместо
единой DEFAULT_IMPERSONATE (providers/_base.py). Обновление до chrome146
(макс. доступный профиль curl_cffi 0.15.0; alias "chrome" НЕ используется -
едет сам при апгрейде библиотеки без ревью) теперь меняется в одном месте.
Guard-тест backend/tests/test_impersonate_single_source.py грепает всё
дерево scraper_kit на литерал "chrome120".
- DOCUMENT_HEADERS ставил Sec-Fetch-Site="none" на уровне сессии, а Referer
добавлялся per-request (curl_cffi мёржит per-request headers поверх
session-level) - живой Referer с "none" рядом не бывает у настоящего
Chrome. Добавлен referer_headers() (Sec-Fetch-Site="cross-site") для
caller'ов с чужедоменным Referer; avito warm-up (yandex/ya.ru -> avito)
теперь его использует. Внутренний avito-search -> avito-detail Referer
(fetch_detail, same-origin) НЕ тронут - отдельный явный комментарий почему.
- Referer прогрева заменён с yandex.ru на ya.ru (живой переход из выдачи
даёт короткий домен). ysclid сознательно не добавлен - значение выпускает
Яндекс, подделка хуже отсутствия.
- warm_up_session/research_in_session считали прогрев успешным по HTTP-
статусу и отсутствию firewall-маркеров, не проверяя антибот-cookies
(__zzatw-*/cfidsw-*, сняты с живого браузера) - detail-батч на такой
"прогретой" сессии сжигал прокси на обречённых 403. Теперь поднимают
AvitoWarmupCookiesMissingError (наследник AvitoBlockedError - существующие
except-блоки/ban_kind/proxy-ротация в pipeline и backfill ловят без
изменений).
Полный backend pytest suite зелёный (4672 passed), ruff check чист.
Refs #3034
`ops/restore-drill.sh` проверял готовность через `pg_isready -U postgres`, то
есть по unix-сокету. Образ postgres во время инициализации поднимает временный
сервер, который на сокете уже отвечает "accepting connections". Проба зеленела
посреди initdb, восстановление начиналось в этот временный сервер и погибало,
как только entrypoint гасил его ради настоящего:
[12:26:22Z] Restoring dump into 'drill' (ON_ERROR_STOP=1 ...)
FATAL: terminating connection due to administrator command
server closed the connection unexpectedly
Воспроизведено на проде 2026-08-21 на живом дампе `tradein-20260821-013001`:
падение через 6 секунд после старта. С `-h 127.0.0.1` тот же дамп проходит
восстановление и доходит до сборки индексов — временный сервер TCP не слушает,
поэтому по TCP проба зеленеет только на настоящем сервере.
Ровно та же проба и ровно по той же причине уже стоит в
`.forgejo/workflows/ci-tradein.yml:137-141`. Тот же дефект живёт и в
`.forgejo/workflows/deploy-tradein.yml:718-721` — там его чинят в PR #3011,
здесь не трогаю.
Refs #2203, #2989
Deliverable 1: ops/backup.sh and tradein-mvp/deploy/backup-tradein-db.sh now
write a sentinel file on every verified-good run. A new ops/check-backup-
staleness.sh (separate cron entry, hourly) alerts via the existing Telegram
channel (same TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID idiom as ops/uptime-
healthcheck.sh, transition-tracked so it doesn't spam) if a sentinel goes
stale. Shared logic (notify/sentinel/state) factored into ops/lib-backup.sh
so it isn't triplicated; the two existing scripts' own hardening (integrity
checks, retention, etc.) is untouched.
Deliverable 2: ops/backup-forgejo.sh — Forgejo (git.gendsgn.ru) has no backup
today. Dumps the shared-postgres `forgejo` DB + tars the bare-repo tree, both
off-box to s3://gendsgn-backups/forgejo/ under a SEPARATE, narrower S3 key
(root-of-bucket writer key stays out of this). The key doesn't exist yet —
the script refuses to run and exits non-zero, loudly, until the four
FORGEJO_S3_* vars are filled in (see the example env file and PR description
for the exact bucket policy JSON to create it with).
Refs #2203, #2989
Deep-review BLOCK на PR #3011: обе правки чинили заявленный симптом только
частично.
077: гард считал pending-строки по source='rosreestr' AND dedup_hash ~
md5-паттерн и пропускал backfill, только если таких строк 0. На чистой БД
они есть — 003_seed_deals.sql сеет синтетические сделки с тем же паттерном,
значит pending > 0 уже на пустом томе, и миграция всё равно падала на
"user mapping not found" (воспроизведено в CI run 8257). Первичный гард
теперь проверяет напрямую наличие USER MAPPING для gendesign_remote
(идиома из app/core/fdw.py:57-62), счётчик pending оставлен вторым —
экономит обращение к FDW, когда мигрировать уже нечего.
deploy-tradein.yml: цикл ожидания готовности postgres ходил по unix-сокету
(pg_isready без -h). На пустом томе временный init-сервер отвечает на
сокете, пока docker-entrypoint-initdb.d ещё прогоняет цепочку миграций —
проба зеленела посреди initdb. Добавлен -h 127.0.0.1 (тот же приём уже
есть в ci-tradein.yml:157) — TCP открывается только после полного
завершения initdb.d.
Отдельно ужесточён sentinel baseline-детекции: раньше «схема уже накачена»
проверялась одной таблицей listings (миграция 002, почти голова цепочки).
Если бы гонка готовности когда-нибудь вернулась, listings был бы уже
создан, а хвост цепочки — ещё нет, и baseline тихо пометил бы недостающие
миграции применёнными без прогона. Теперь проверяются оба конца — listings
(голова) и houses_geog_gist_idx, индекс из миграции 270 (хвост); при
несовпадении (ровно один конец на месте) деплой падает громко с explicit
ошибкой вместо угадывания.
Вне транзакции SET LOCAL молча ничего не делает — гейт это ловит, и он
поймал меня второй раз подряд, теперь локально, до CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Гейт CI «блокирующий DDL без lock_timeout» покраснел по делу: ALTER TABLE
берёт ACCESS EXCLUSIVE на houses и при живом писателе ждал бы бесконечно,
копя очередь. 5с — миграция падает честно и деплой перезапускается.
Предыдущий пустой коммит «перезапуск — флуктуация раннера» был ошибкой
диагноза: я не дочитал лог changes до ::error и приписал падение
checkout'у. Единственный источник истины — строка ::error в логе самого
changes; «Job 'changes' failed» у зависимых джобов и «'runs-on' key not
defined» в act_runner v6.3.1 — шум.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Единственный реально упавший джоб — changes (задача 22320): лог обрывается на
'Setting up auth' checkout'а после 'Non-terminating error while running git clone:
some refs were not updated'. Три зависимых джоба помечены «Job 'changes' failed»
как пропущенные, не упавшие. Строка «'runs-on' key not defined in CI/changes»
в логах act_runner v6.3.1 стоит у десятков успешных задач за день — шум при
разборе needs, не причина. YAML головы PR байт-идентичен origin/main.
Код не меняется.
В SQL-формуле relevance_score кандидат без year_built получает штраф 0 —
столько же, сколько точное попадание в год, и лучше, чем кандидат с
известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие
данных выигрывает у знания и возвышает источник с худшей полнотой.
Флаг estimate_unknown_attr_penalty_enabled (default OFF): кандидат с NULL
получает МЕДИАННЫЙ по пулу штраф того же признака среди тех, у кого он
известен — не наказание и не награда; считается по тем же термам, что
SQL (abs(Δyear)/12.0, 1.5 за несовпадение типа). Лежит в Python-слое
после SQL, рядом с kitchen/ceiling (#2012), по тому же контракту:
включать — только по бэктесту.
Без чего флаг был бы мёртв (и был в первой редакции): _ANALOG_SELECT_COLS
не выбирал year_built/house_type — SQL считал по ним CASE, но в словарь
кандидата колонки не попадали, Python-слой видел None у ВСЕХ и не штрафовал
никого по построению. Probe-лог в прод-оверлее: pool=30 null_year=30.
Добавлены в _ANALOG_SELECT_COLS, во внешние SELECT тиров H/W и во
внутренний base Tier W (он строится явным списком). Контроль: флаг OFF с
колонками и без — метрики бэктеста идентичны до сотых. На этот инвариант
стоит тест по исходнику запросов.
Живой A/B (бэктест в прод-контейнере, оверлей /tmp/ab, 300 сделок ЕКБ
Q2 2026, одна и та же выборка в обоих прогонах — проверено по deal_id):
состав топ-50: сменилось 96 слотов из 5 915 (1.6 %), 27 сделок из 300
источники: avito 55.7→55.3 %, cian 24.5→24.8 %, yandex 12.7→12.6 %
цена: MAPE 16.70→16.70, bias −4.52→−4.52, coverage 84.46→84.46 —
идентично до сотых по всем срезам (сегменты, комнатность).
Нижний Тагил (300 сделок, пулы 21/p90 40): 0 из 5 666 слотов сменилось.
Почему эффект в разы меньше замера задачи (×0.20 avito): тот замер шёл
по SQL тира H без стратификации. В боевом пути 54.9 % слотов топ-50 —
гарантированная квота MIN_ANALOGS_PER_SOURCE=5, раздаётся ДО сортировки
остатка; и у avito в топ-50 NULL-год лишь у 25.7 % (задача мерила 56 %
по всем активным объявлениям). Обе величины измерены по фикстурам A/B.
Что это значит: артефакт в формуле есть, флаг его корректно лечит, но на
итоговую цену он не влияет измеримо. Включать по умолчанию оснований
нет — и это и есть ответ, ради которого флаг заводился вместо правки.
pytest tradein-mvp/backend: test_2936 7 passed; гейт фикстуры и
roundtrip 8 passed; -k "estimat or analog" 735 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Остальные семь чеков прошли за минуты, backend-tests стоял 1ч30м без
единого обновления статуса. Actions API на этом Forgejo недоступен
(/actions/tasks обрывает соединение), перезапуск — только новым
коммитом. Код не меняется.
Задача формулировала «импорт каждый день рапортует done с total_seen=0».
Проверка на проде показала другое: импорт исправен — ежедневно вычитывает
все 96 974 строки FDW-источника и честно их пропускает (rows_fetched =
rows_skipped = 96974), а `total_seen` — поле админ-витрины, не счётчик
импорта. Источник gendesign.rosreestr_deals стоял на Q1 2026 (загружен
30.04), хотя Q2 2026 опубликован Росреестром 10.07 и poll заметил его
14.08 (available=1). Оба сторожа — poll и deals_freshness_monitor —
сработали и семь событий ушли в GlitchTip, где 0 правил / 0 адресатов /
0 отправок.
Корень, которого в задаче не было: rosreestr_deals партиционирована по
period_start_date, партиции созданы списком в 01_schema «2024 Q3 — 2026
Q1», и ничто новые не создаёт. Загрузка Q2 21.08 упала:
ERROR: no partition of relation "rosreestr_deals" found for row
DETAIL: (period_start_date) = (2026-04-01)
То есть даже оператор, запустив загрузчик по подсказке poll, получил бы
отказ. Это и объясняет, почему poll сделан «только сообщить».
Что сделано:
• миграция 193 — партиции Q2, Q3, Q4 2026 с запасом, идемпотентно, с
lock_timeout; индексы наследуются от родителя (проверено: 4 на 2026q2);
• JOBS загрузчика — 2026Q2–Q4 (квартал без CSV честно SKIP);
• ловушка set -e в загрузчике: resolve_csv сигналит «файла нет» кодом 1,
и первый же квартал без CSV молча ронял ВЕСЬ прогон до строки SKIP — на
проде с одним Q2-файлом скрипт завершался rc=0, не напечатав ни строки.
`|| true` на вызове; после правки боевой прогон на VPS: 12 кварталов,
2026Q2 «уже загружен (741874 строк)», остальные SKIP, rc=0;
• тест-сторож горизонта: партиция обязана существовать на последний
публикуемый квартал (+20 дней лага после конца квартала; Q2 2026 вышел
10.07) и на следующий — чтобы предупреждение приходило за квартал до
отказа, а не в день публикации. Читает pg_inherits живого Postgres.
Красная сторона воспроизводима на проде, где миграция уже применена:
DETACH партиции в откатываемой транзакции → головная краснеет по
значению («нет партиции на квартал 2026-04-01»), откат возвращает
партицию (проверено: 12 партиций после теста). Без БД — skip с
причиной, в allowlist; календарный тест идёт везде.
Сам Q2 загружен на прод по штатному пути: 741 874 строки в
rosreestr_deals (ЕКБ-фильтр 13 654), import-rosreestr.sh → tradein.deals
+11 649 сделок, max(deal_date) 2026-01-01 → 2026-04-01.
deals_freshness_monitor на следующем тике: alert 0, latest_quarter 2.
pytest backend/tests/sql — 55 passed (через туннель к проду).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
verify_dump_integrity() в ops/backup.sh и tradein-mvp/deploy/backup-tradein-db.sh
делала `grep -qF "$trailer"`, где $trailer = "-- PostgreSQL database dump
complete". Ведущие -- в аргументе grep трактует как конец опций/флаг, без
разделителя команда падает: `grep: unrecognized option '-- PostgreSQL...'`.
Проверка из-за этого ВСЕГДА возвращала "трейлера нет" — не потому что дамп
оборван, а потому что сама проверка не могла выполниться. Вызывающий код
удалял только что созданный валидный дамп и завершался с ошибкой; ретеншен
не успевал отработать (ранний exit) — свежие бэкапы не создавались никогда,
старые копии оставались молча.
Воспроизведено вручную на проде: bash
/opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh удалил свежий дамп с
сообщением "дамп оборван?".
Фикс: `grep -qF -- "$trailer"` — `--` явно завершает список опций grep.
Регрессионный тест (backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py)
исполняет РЕАЛЬНУЮ verify_dump_integrity() из обоих скриптов на настоящем
gzip-потоке через gunzip|tail|grep — не читает исходник текстом. Проверено
локально: падает на добаговой версии с тем же "unrecognized option", зелен
на исправленной.
Блокер переезда (#2991). Контейнер шёл на ПОЛНОСТЬЮ стоковой конфигурации:
ни command:, ни смонтированного postgresql.conf. Замер прода 2026-08-20 —
169 млрд blks_read за 91,75 сут ≈ 175 МБ/с мимо кеша при shared_buffers 128 МБ.
Поправка к диагнозу из issue: 288 чекпойнтов в сутки — это ровно 24ч/288 =
5 минут, то есть дефолтный checkpoint_timeout, а НЕ max_wal_size. При WAL
7 ГБ/сут лимит в 1 ГБ дал бы 7 чекпойнтов, а не 288. Поэтому поднят именно
timeout до 30min — правка одного max_wal_size не изменила бы ничего.
Реже чекпойнты → реже full-page images, а они сейчас 55-86% объёма WAL.
Значения подобраны под ТЕКУЩИЙ сервер и не выходят за mem_limit=3g:
shared_buffers 768MB (6× от стоковых), work_mem 16MB, maintenance_work_mem
256MB (autovacuum по listings идёт 193 раза в сутки).
max_wal_size 4GB, а не 8GB: на диске 38 ГБ свободно, а pg_wal растёт до этого
значения. При timeout=30min между чекпойнтами копится ~0,15 ГБ — запас большой.
wal_compression=zstd — самая дешёвая победа при такой доле FPI.
random_page_cost 1.1 вместо стоковых 4: диск NVMe, а 4 — настройка под HDD,
из-за неё планировщик недооценивал индексные сканы.
pg_stat_statements подключён через shared_preload_libraries — расширение было
в образе, но не активировано, и прошлый разбор пришлось вести по косвенным
признакам.
shm_size 512m: дефолтные 64 МБ /dev/shm ронял параллельные воркеры на тяжёлых
PostGIS-сортировках.
mem_limit оставлен 3g. В комментарии зафиксировано, что при переезде его надо
поднимать ДО правки shared_buffers: лимит контейнера срабатывает раньше
postgresql.conf, и shared_buffers=16GB при mem_limit=3g даёт OOM на старте.
Refs #2991, #2989
Блокер переезда (#2990). Чистый старт на пустом томе падал: 077 читает foreign
table gendesign_rosreestr_deals, а USER MAPPING создаёт бэкенд при старте
(app/core/fdw.py), то есть ПОСЛЕ docker-entrypoint-initdb.d. Контейнер не
поднимался вообще. Путь «пустой том» на реальном железе не исполнялся ни разу,
а CI этот файл явно пропускал — гейт, который должен был поймать, был ослаблен.
Проверено по всем 14 миграциям, упоминающим FDW-таблицы: читает ровно одна —
077. Остальные только CREATE/DROP FOREIGN TABLE и COMMENT, им ни USER MAPPING,
ни связь с чужой БД не нужны.
077 не удалена, а сделана самозащитной: гард считает строки в md5-форме и
выходит раньше обращения к FDW, если мигрировать нечего. На чистой БД таких
строк нет по определению. Удаление файла было бы неверным — прод помнит
миграции по bare-filename в _schema_migrations, и
test_applied_migration_is_not_renamed_or_deleted падает на удалении.
Исключение в ci-tradein.yml снято: теперь цепочка применяется целиком, то есть
CI сам стал репетицией чистого старта.
Отдельно закрыт тихий отказ в deploy-tradein.yml. Ветка baseline срабатывала по
одному лишь отсутствию _schema_migrations, а это состояние неоднозначно: так
выглядит и наполненный прод до внедрения tracking, и пустая БД нового сервера.
Во втором случае baseline пометил бы все миграции применёнными, ни одной не
прогнав, и деплой уехал бы зелёным на пустой схеме. Добавлен sentinel по
listings: пусто → baseline пропускается, цепочка применяется с нуля.
Refs #2990, #2989
Находка 3 задачи («23 дома лежат вне области 66, гарда на приёме нет»)
подтвердилась, и к ней добавились масштаб и причина.
Масштаб: к этим домам привязано 591 объявление. Адреса екатеринбургские
(«Ул. 8 Марта», «Крауля», «Амундсена»), координаты — Варшава, Белград,
Таллин, Владивосток, Ижевск.
Причина: 22 из 23 несут в raw_payload след разового бэкфилла
`full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без
резолва города. Живой путь при этом ЧИСТ — 0 записей вне региона в
geocode_cache из 10 448 и 2 из 88 544 у listings; скрипта в репозитории
нет. Это исторический осадок, а не текущая утечка, и гард нужен не
столько живому пути, сколько следующему разовому скрипту.
17 из 22 помечены геокодером `precision: "exact"`. Точность отвечает на
«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть
не может — поэтому проверяется ПРИНАДЛЕЖНОСТЬ.
Инвариант нарочно не географический: «дом рядом со своими объявлениями»,
а не «дом внутри рамки области». Рамка сломалась бы при выходе в Москву —
ровно то, ради чего заведена #2996. Он же строго сильнее: ловит 25 домов
против 23 у рамки, и оба лишних проверены («Ул. Белинского/Фурманова» за
207 км, 34 объявления).
Порог 100 км не подобран на глаз. Замер по 9 052 домам: ближе 1 км —
8 914 (98.5 %), 25-100 км — 9 настоящих пригородов (Сарапулка, Кедровка,
Чусовское Озеро, Ревда, Первоуральск; самый дальний 46.7 км), дальше
100 км — 25 (ближайший 119.2, максимум 5 079). Между 46.7 и 119.2 км нет
НИ ОДНОГО дома: порог лежит в середине пустого промежутка.
Первая редакция клала счётчик полем в /scraper/data-quality. Замер это
остановил: запрос стоит ~445 мс на тёплом кэше, а обе существующие
выборки той ручки вместе — 27 мс, при опросе фронтом каждые 120 с. То
есть 17-кратное удорожание ради числа, которое меняется раз в месяцы.
Проверка вынесена в отдельную ручку по требованию, и на возврат в горячий
путь поставлен контроль-тест.
Ручка отдаёт не только счётчик, но и масштаб (список домов + сколько
объявлений привязано) — по одному числу «25» решение об очистке не
принять.
Двусторонне: против origin/main три теста красные, краснота везде по
значению — ни одного ImportError/AttributeError. Контроль
test_check_stays_out_of_the_polled_endpoint зелёный с обеих сторон.
pytest tradein-mvp/backend — 4642 passed, 23 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aws-cli v2 (образ amazon/aws-cli:latest) использует собственный набор
корневых сертификатов из botocore, а не системное хранилище контейнера.
В нём нет корня, которым подписан сертификат Selectel S3
(s3.ru-1.storage.selcloud.ru), поэтому docker run падал на
SSL validation failed / CERTIFICATE_VERIFY_FAILED: self-signed
certificate in certificate chain — и ночной S3-upload не проходил
каждый раз, хотя ключи и bucket были верны.
Проверено на проде: без AWS_CA_BUNDLE — SSL validation failed; с
AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt (системное хранилище
контейнера, содержит нужный GlobalSign-корень) — upload 11 МБ проходит,
код возврата 0, объект подтверждён чтением вторым ключом.
Правка — одна переменная окружения в docker run в обоих скриптах:
- ops/backup.sh (main DB backup)
- tradein-mvp/deploy/backup-tradein-db.sh (tradein DB backup, #3004)
`run_cian_full_load` передавал `secondary_only=True` жёстко, поэтому
включить новостройки в полный обход можно было только деплоем. Теперь это
параметр с ТЕМ ЖЕ дефолтом `True` — поведение прода не меняется ни на
строку, но решение становится правкой одной ячейки
`scrape_schedules.default_params`, а не выкаткой кода. Откат — тем же
движением.
Почему это важно именно здесь. Новостройки НЕ пропускаются при запросе:
они скачиваются, разбираются и выбрасываются последним шагом
(`cian/serp.py`), потому что SERP-параметр `object_type=1` у Cian
ненадёжен (~5 % выдачи) и фильтруют по authoritative `listing_segment`
после парсинга. Проба бакета берёт `totalOffers` из Redux-состояния SERP
и считает `pages_needed = ceil(totalOffers / offers_per_page)`, а
`totalOffers` включает ОБЕ категории — то есть страницы с новостройками
уже скачаны, лимит страниц и антибан-бюджет за них уже заплачены.
Включение стоит ноль дополнительных запросов.
Заодно `dropped_novostroyki` сохраняется в counters прогона. Счётчик
логировался (`dropped_nb=`), но не персистился, и ответить «сколько
инвентаря выбрасывает полный обход» задним числом было нечем: логи за
17.08 уже ротировались — `docker logs --since 120h` не находит ни строки
«cian:» ни в одном контейнере. Тот же довод, по которому рядом заведён
`partial_buckets`. Копится в атрибуте инстанса, а не аргументом
`on_bucket`: у колбэка есть внешние реализации, менять его сигнатуру
ради счётчика нельзя. Сброс на каждый прогон — инстанс переиспользуется.
Замер, ради которого это делается (прод 21.08): месячный охват свипа
cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и
avito/novostroyki; 11 993 активные строки, медианный возраст 81 сутки,
10 585 старше 30 суток. Подробности и оговорки — в #1781 и #2994.
Двусторонне: против origin/main пять тестов красные, и краснота везде по
значению, а не по отсутствию символа — ни одного KeyError. Сообщения
перечисляют фактическое состояние («параметра нет в сигнатуре; параметры:
[...]», «поля нет в запросе; поля: [...]»).
Контроли зелёные с обеих сторон: дефолт остаётся True (иначе правка тихо
включила бы сбор новостроек на проде — это отдельное решение с замером);
фильтр при `secondary_only=True` остаётся на месте и по-прежнему зависит
от флага.
pytest tradein-mvp/backend — 4644 passed, 23 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
listings.tsv (GENERATED ALWAYS ... STORED) пересчитывался на КАЖДОМ UPDATE
listings независимо от того, менялись ли description/address, и заново
перетостивался — ~5 ГБ TOAST-оборота за 91 день.
Разведка: /api/v1/search (search_query.py) читает listings_search_mv, не
listings напрямую, а витрина сама считает to_tsvector из сырых
description/address/developer_name при каждом REFRESH — l.tsv она никогда не
читала. DROP EXPRESSION безопасен для поиска и, по ATExecDropExpression
(PG16), не вызывает table rewrite — catalog-only операция.
listings_tsv_idx (GIN, 116 MB) снесён отдельно: 0 idx_scan за всю историю БД,
не обслуживает ни один constraint. Колонка tsv остаётся (заморожена, без
читателей) — DROP COLUMN вне рамок этой миграции.
Refs #2992, #2989
`risks.geology_risk_label` назывался геологическим риском, а вычислялся так:
high — если подтопление
medium — если шум ≥ 65 дБ
low — иначе
Геологии в нём не было ни одного бита. При этом `cad_risk_zones` пуста
(0 строк, писателя нет — #2934 п.6), поэтому подтопление приходило только
из OSM-прокси «река ближе 200 м». На тихом участке без реки поле ВСЕГДА
говорило «low» — зелёный вердикт, ни разу не подкреплённый проверкой
геологии.
Поле убрано, а не переименовано: соседний блок `geology` честно отдаёт
`data_available: false`, когда данных нет. Замена не нужна.
Удаление поля из публичного ответа обосновано замером, а не словом:
потребителей нет ни в backend, ни во фронте, ни в §19-allowlist чата, ни
в экспортёрах; в схеме `risks: dict[str, Any]`, поэтому OpenAPI не
меняется. На это поставлен отдельный тест, который перечитывает дерево
исходников — иначе обоснование держалось бы на моём слове.
Двусторонне: против origin/main три теста красные («метка всё ещё
выдаётся», «шум по-прежнему участвует в риск-блоке», «поле где-то ещё
читается»). Контроли зелёные с обеих сторон: измеренный `noise_score`
остаётся на месте, `flood_zone` тоже (его судьба — отдельный пункт
задачи).
Контроль подмены отдельно: тест запрещает словесные градации риска в
блоке, иначе «починка» переименованием оставила бы тот же обман.
Проверка потребителей отличает комментарий от использования — иначе она
краснеет на собственном объяснении правки (на это я наступал трижды за
сутки, см. соседние PR).
pytest backend/tests/api/ — 377 passed, 1 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Оба утверждения в разделе Path triggers устарели ровно из-за коммита
2d2336cd в этой же ветке: список больше не содержит ops/docker-prune.sh
(теперь ops/*.sh), а предупреждение «добавлять в paths явно» для
любого нового ops/<name>.sh больше не верно — глоб их подхватывает сам.
Осталась одна деталь, о которой правда надо помнить: одиночная звёздочка
не пересекает /, так что новый ПОДКАТАЛОГ внутри ops/ (как db-bootstrap/,
glitchtip-auth-forwarder/) под глоб не попадает и всё ещё требует своей
строки в paths — иначе тот же класс бага (#2887 / #2203) повторится для
подкаталога.
paths-filter в deploy.yml знал только про ops/docker-prune.sh (#2887) — правка
ops/backup.sh или новый ops/restore-drill.sh из этого же PR не долетели бы до
/opt/gendesign: деплой не триггерится -> git reset --hard origin/main не
исполняется -> cron на VM месяцами крутит старую версию, молча.
Точечный список сам по себе и есть баг: #2887 добавил только тот файл, о
котором тогда шла речь, и следующий новый ops-скрипт (backup.sh) остался
за бортом. Глоб ops/*.sh закрывает класс целиком — не матчит подпути
(ops/db-bootstrap/**, ops/glitchtip-auth-forwarder/**), у них свои explicit
триггеры уже есть, дублирования нет.
core.filemode=false на Windows-чекауте молча создал файл как 100644;
остальные ops/*.sh — 100755, restore-drill.sh должен быть исполняемым
так же (cron/deploy конвенция ops/backup.sh).
- ops/backup.sh и tradein-mvp/deploy/backup-tradein-db.sh теперь дампят
globals (pg_dumpall --globals-only) отдельным файлом с той же ретенцией
и той же S3-выгрузкой — pg_dump по определению не включает роли/GRANT.
- Обе выгрузки проходят gzip -t + проверку трейлера дампа перед тем как
считаться успешными; при провале файл удаляется, ретенция не трогается,
выход ненулевой.
- Убран 2>/dev/null у pg_dump в обоих скриптах — ошибка дампа теперь
видна в логе, а не глотается молча.
- tradein-backup.sh получил S3-выгрузку (по образцу ops/backup.sh, те же
4 переменные, тот же способ через aws-cli контейнер) и env-переопределяемый
порог минимального размера дампа; источник переменных —
/etc/default/tradein-backup с фолбэком на /etc/default/gendesign-backup.
- Новый ops/restore-drill.sh — учебное восстановление в одноразовый
postgis-контейнер без прод-томов, никогда не трогает боевую БД (в отличие
от ops/restore.sh, который восстанавливает В БОЕВУЮ базу).
1. `load_water_reserves_from_docx` собирал `result` с ключом `period`,
печатал ПОЛНЫЙ словарь в лог, а возвращал
`{k: v for … if isinstance(v, int)}` — период это строка или None,
поэтому выбрасывался всегда. По логам казалось, что период отдаётся;
вызывающий не получал его ни разу.
Фильтр стоял ради аннотации `dict[str, int]` и ничего не защищал:
соседняя ветка `load_water_reserves` кладёт в тот же словарь
`{"error": str(...)}`, а единственный потребитель — задача
`sync_water_reserves` — результат логирует и возвращает как есть.
Период полезен: без него «загружено 42 записи» не отличить от
прошлогодних. Аннотация исправлена, иначе следующий проход mypy вернул
бы фильтр обратно — на это поставлен отдельный тест.
2. `get_sqlite_info` — TOCTOU: `p.exists()`, затем незащищённый
`p.stat()`. try/except покрывал только `sqlite3.connect` ниже, поэтому
OSError из stat улетал наружу и превращал диагностическую функцию в
источник отказа. Файл между проверками реально исчезает — его
переписывает выгрузка Объектива. Теперь отдаём то, что успели узнать,
с ключом `stat_error`.
3. `place_program` — предупреждение «участок мал» печатало КАТАЛОЖНЫЕ
`house.footprint_*`, хотя ставили по `fp_w`/`fp_d`. При
переопределённом в программе габарите сообщение называло размер,
которым никто не пытался ставить, и уводило от причины.
Двусторонне: против origin/main четыре теста красные с конкретными
значениями («период выброшен из ответа: {'records': 1, 'inserted': 1,
'updated': 0}», «аннотация всё ещё требует только int: dict[str, int]»).
Контроли зелёные с обеих сторон: отсутствующий файл по-прежнему даёт
exists=False без ошибки; `fp_w`/`fp_d` — действительно те размеры,
которыми ставят (иначе первый тест сверял бы имена, а не смысл).
pytest backend/tests/services/ — 3207 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. `QuarterDump` (nspd_client): «Default = только core, чтобы не сжигать
rate-limit на 17 запросов». Фактический дефолт `search_by_quarter` —
`include_zouit=True`, то есть 5 ЗОУИТ-слоёв входят в дефолтный вызов.
Числа 17 тоже нет: territorial_zones/red_lines/engineering и все ЗОУИТ
идут через grid-walk при grid_n=7, по 49 запросов КАЖДЫЙ — дефолтный
дамп это сотни запросов. Экономит rate-limit только include_risks=False.
Докстрока самого метода 640 строками ниже говорит верно («Default
True») — правильный образец лежал рядом с дефектом.
2. `find_active_on_demand_job` (cadastre_fetch): «Если в БД есть FAILED
on-demand за последние 60 секунд — тоже None». В SQL нет ни слова
'failed', ни какого-либо временного фильтра. Обещание вдвойне вредно:
подразумевало, что неуспешная джоба СТАРШЕ минуты вернётся как
активная (не вернётся), и отправляло отлаживающего искать окно,
которого нет.
Гейты сверяют утверждение докстроки с кодом, а не читаемость текста:
обещание «только core» требует `include_zouit=False` в сигнатуре;
обещание минутного окна требует временного фильтра в теле.
Двусторонне: против origin/main три гейта красные с конкретными
сообщениями. Контроли зелёные с обеих сторон — характеризующий фиксирует
фактические три статуса в SQL, а test_docstrings_state_the_actual_behaviour
ловит «починку» через вычёркивание неудобной фразы.
Два подводных камня, на которые наступил и оставил защиту:
- гейт ищет обещание по тексту, поэтому старые формулировки в докстроках
ПЕРЕСКАЗАНЫ, а не процитированы — иначе он не отличает цитату от
утверждения (оговорено прямо в тексте докстроки);
- тело функции нельзя брать как последний кусок разбиения по тройным
кавычкам: SQL сам в них обёрнут, и проверка шла бы по огрызку после
запроса. Из-за этого один гейт проходил по случайности. Вынесен
хелпер `_body`.
pytest backend/tests/services/ — 3199 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cad_utility_label` брался от ПЕРВОГО overlap'а с распознанным
`network_kind`, а покрытие агрегировалось по ВСЕМУ bucket'у. Участок под
двумя видами охранных зон получал в отчёте конкретную причину, дающую
малую часть площади:
«Сетевое обременение (теплоснабжение) покрывает 74% участка»
← теплоснабжение даёт 10%, остальные 64% — инженерные коммуникации
Это не редкость. Прод 20.08.2026: 316 пересечений охранных зон РАЗНЫХ
видов, 155 зон вовлечено; самая частая пара — «тепловых сетей» ×
«инженерных коммуникаций» (296 из 316).
Теперь копятся ВСЕ различённые виды и называются через запятую;
множественное число, когда их больше одного. Покрытие — их объединение,
и подпись это отражает.
Заодно снята зависимость от порядка: подпись бралась от первого overlap'а,
а он определяется `ORDER BY reg_numb_border, id`, который к покрытию
отношения не имеет. Виды сортируются.
Проверка попутно опровергла ДОВОД пункта эпика. Пункт говорит о смешении
сетевых зон с keyword-совпадениями без network_kind. На проде таких ноль:
из 3493 строк cad_zouit — 103 СЗЗ-предупреждения, 1936 с распознанной
сетью, 1454 generic warning, и НИ ОДНОЙ «только по ключевому слову»
(classify_network_zone уже покрывает все встречающиеся шаблоны). Вывод
пункта — «конкретная причина приписывается чужой площади» — верен, но по
другой причине: смешиваются РАЗНЫЕ ВИДЫ СЕТЕЙ, а не сети с keyword'ами.
Двусторонне: против origin/main три теста красные, головной — с полной
строкой, которую увидел бы пользователь. Контроли зелёные с обеих сторон:
один вид сохраняет прежнюю формулировку в единственном числе, две зоны
одного вида не дают дубль в подписи, area-gate не тронут (тонкая полоса
остаётся warning).
pytest test_gate_verdict + services/site_finder — 727 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`synthesize_teap_from_buildability` проверяла параметры только на `> 0`.
Процент застройки 150 давал пятно БОЛЬШЕ участка (10 000 м² → 15 000 м²),
а дальше — жилую площадь, число квартир и выручку: физически невозможные
числа, поданные как обычные цифры финмодели. КСИТ 500 давал GFA
5 000 000 м² на гектаре.
Параметры приходят из ПЗЗ-регламента (`zone_regulation_cache`) — внешние
разобранные данные, то есть граница доверия.
Невозможное значение ОТБРАСЫВАЕТСЯ, а не роняет расчёт: если рядом есть
КСИТ, GFA считается по нему и остаётся верной. Лучше отсутствие
параметра, чем неверный. Если вменяемых не осталось — None, и caller
штатно показывает отсутствие финоценки с caveat, а не ноль.
Границы взяты с запасом к реальным данным прода 20.08.2026 (33 строки
zone_regulation_cache: pct 0..100, far 1..4, floors 0..5): pct ≤ 100,
far ≤ 30, этажей ≤ 100 — сито против порчи разбора, а не норматив.
ВТОРОЙ дефект, найденный этими же тестами и существовавший до правки:
ветка «нет ни процента, ни этажности → пятно = GFA» неявно предполагает
один этаж, и при КСИТ > 1 давала пятно больше участка (10 000 м² с far=2
→ 20 000 м²). Добавлен физический инвариант «пятно ≤ участок» — не
эвристика, а геометрия, и стоит он ОДИН раз после всех ветвей, чтобы
держаться и для будущих способов оценки пятна. GFA при этом не меняется.
Двусторонне: против origin/main четыре теста красные с конкретными
невозможными значениями («пятно 15000.0 больше участка 10000.0»,
«GFA=5000000.0»). Восемь контролей зелёные с обеих сторон — среди них
пять сочетаний (pct, far, floors), взятых ДОСЛОВНО с прода, и граница
100 % застройки, которая законна и на проде есть.
pytest test_parcel_financial + services/generative + новый файл — 185 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обе функции ходят через один браузерный контекст, под один и тот же WAF.
`get_json` держит до пяти попыток с экспоненциальным backoff на 429/5xx/0,
а `download_binary` не имел ретраев вовсе: один 429 ронял загрузку
картинки насовсем, и вызывающий (`download_plan_image`, `download_photos`)
писал в лог «не удалось» — неотличимо от «файла нет».
Правильный образец лежал в этом же классе, двадцатью строками выше.
Непереходные коды (403, 404) поднимаются сразу, без ожидания: повтор их
не изменит, а лишний стук под WAF вредит. Разбор статуса вынесен ЗА
семафор — sleep не должен держать слот.
Двусторонне: против origin/main транзиентные тесты красные с конкретным
значением («вместо байтов получили RuntimeError('binary http 429…');
попыток=1»), ни одного ImportError/TypeError.
Контроли зелёные с обеих сторон: 403 и 404 не повторяются, исчерпание
попыток даёт честную ошибку, а не пустые байты, успех с первой попытки не
порождает лишних запросов.
Отдельный контроль на паузы: без него «ретраит» и «долбит без пауз»
неотличимы в тесте, а под WAF разница между ними решающая — проверяется,
что задержки растут как 1, 2 секунды.
pytest backend/tests/services/scrapers/ — 340 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Номер 191 занят PR #2984 (backfill act_date), который уходит в main
раньше. Обе ветки прошли CI со своим 191 — проверка идёт по голове
ветки и о занятости номера соседом узнать не может.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`UNIQUE (doc_group, doc_num)` вводился, чтобы склеивать ОДИН документ,
пришедший из двух схем портала. Замер 20.08.2026 показал, что задача,
ради которой ключ введён, почти отсутствует, а побочный эффект огромен:
docNum у ГИСОГД НЕ уникален — разрешение и изменения к нему носят один
номер.
группа документов различных key различных docNum схлопывается
DocRS 6098 6096 4305 1793
DocRV 5419 5415 4969 450
DocIZ 548 547 393 155
общих docNum между схемами (DocRS): 2 ← ради этого ключ и вводился
общих key между схемами (DocRS): 2 ← те же два
На проде 9182 строки против 12 065 документов на портале — нет 23.9 %
реестра. Пример 66-06-06-2026: портал отдаёт два документа (key …719586 —
само разрешение, key …752293 — изменения к нему), а UPSERT с
предпочтением позднего date_reg оставлял только изменение. Так вытеснено
598 из 4320 строк РНС (13.8 %) — в §6 на месте разрешения показывается
изменение к нему, без признака подмены.
Ключ стал `UNIQUE (source_key)`: разделяет разрешение и изменения (разные
key) и по-прежнему склеивает настоящие межсхемные дубли (у них key
ОБЩИЙ — ровно 7 записей по всем группам). Дедуп перед сменой не нужен:
source_key на проде уже уникален (9182 из 9182, NOT NULL).
Заодно группа DocIZ добавлена в GROUP_CODE — её не было вовсе, 548
документов не грузились. CHECK расширен значением 'IZ'.
§6 сужена до РНС/РВЭ ЯВНО: агрегат обещает total_count = rs_count +
rv_count, а строки 'IZ' попадали бы в total и ни в один счётчик.
Показывать ли изменения отдельной строкой — вопрос продуктовый (#2986);
до его решения сужение стоит в запросе, а не держится на том, что таких
строк «пока нет».
Проверки:
- два гейта на лоадер (GROUP_CODE и цель ON CONFLICT) — БЕЗ базы,
двусторонние: на origin/main дают конкретные неверные значения
({'DocRS','DocRV'} и старый ON CONFLICT в тексте запроса);
- гейт на §6 и контроль инварианта total = rs + rv на данных — красные
на origin/main;
- герметичная репетиция миграции на временной копии: со старым ключом
разрешение и изменение схлопываются в одну строку (и остаётся именно
изменение — как на проде), после миграции живут раздельно; межсхемный
дубль по-прежнему склеивается; CHECK принимает 'IZ' и отвергает мусор.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_page_contains_table` документировалась как «hint-только режим (оглавление,
перекрёстные ссылки) даёт false positive и подавляется». Кода подавления в
функции никогда не было — это `bool(cap.search(text))`. Обещание защиты,
которой нет, опаснее её отсутствия: читающий не станет её добавлять.
Замер уточнил и сам пункт эпика. Приведённый в нём пример перекрёстной
ссылки «показатели приведены в таблице 12» регекс НЕ ловит: он требует
именительное «Таблица N», поэтому «в таблице 12», «см. Таблицу 12»,
«табл. 12» дают False. Опасно ровно ОГЛАВЛЕНИЕ — там падеж тот же
именительный, и «Таблица 11 Баланс территории ..... 34» неотличима от
подписи. Это зафиксировано характеризующим тестом, чтобы следующая
попытка подавления целилась в признаки оглавления (точки-выноски, номер
страницы в конце), а не в падежи.
Подавление здесь не реализовано сознательно: `ekb_ppt_tep` на проде пуста
(0 строк), URL в `_SEED_DOCS` — заглушка, живых PDF нет. Эвристику отсева
не на чем откалибровать, а правило, придуманное без образцов, ловит ровно
те случаи, которые придумали вместе с ним.
Отдельно исправлен комментарий сида: хост `gisogd.ekburg.ru`, названный
там местом, «где лежит реальный URL», НЕ СУЩЕСТВУЕТ — DNS не резолвит его
ни с рабочей машины, ни с прод-хоста (20.08.2026). Комментарий отправлял
искать документ на портале, которого нет. Назван живой портал
`gisogd66.midural.ru` и способ перечислить его разделы.
Двусторонне: против origin/main два гейта красные — «докстрока обещает
подавление, а в теле только поиск подстроки» и «комментарий сида не
предупреждает, что хост мёртв». Характеризующие тесты зелёные с обеих
сторон: они фиксируют фактическое поведение, а контроль
test_docstring_names_the_actual_behaviour ловит «починку» через
вычёркивание неудобной фразы.
Гейт ищет обещание по слову, поэтому старая формулировка в докстроке
пересказана, а не процитирована — иначе он не отличил бы цитату от
утверждения; это оговорено прямо в тексте.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
заканчивается `ON CONFLICT DO NOTHING`, а не DO UPDATE, поэтому
пятничный прогон (`0 7 * * fri`) существующие строки не перезапишет.
Без этой миграции 11 строк остались бы с датой 2004 года навсегда —
правка выглядела бы сделанной, а данные на проде остались бы кривыми.
Замер прода 20.08.2026:
89adb28a… развязка Базовый/Комсомольская/Сибирский тракт 9 строк
9b9d9a99… улица Энергостроителей 2 строки
обе группы: act_date = 2004-07-06
Верные даты не угаданы: оба PDF загружены с екатеринбург.рф и
распознаны тем же трактом, что использует загрузчик (ocr_pdf_text), и в
обоих настоящее основание — постановление Администрации города:
№ 1413 от 27.05.2022 и № 259 от 12.02.2020 соответственно.
Сужение по doc_url обязательно: без него UPDATE задел бы любую строку с
06.07.2004, включая те, где эта дата настоящая. Миграция идемпотентна —
условие `act_date = '2004-07-06'` при повторе не выполнится.
Тест герметичный, прогоняет ТЕЛО миграции целиком на временной копии в
прод-форме (9+2 целевых + 2 контрольных посторонних). Контроль-двойник
`test_without_the_migration_rows_stay_wrong` обязателен: без него тест
неотличим от «оно и так было правильно». Мутационно проверено сужение —
снятие условия по doc_url роняет
test_other_documents_with_same_date_are_untouched.
pytest backend/tests/sql/test_2464_act_date_backfill.py — 6 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_SUPPLY_ONLY_LOTS_SQL` раскладывал `area_pd IS NULL` в ту же корзину
`'<25'`, что и настоящие студии. Замер прода 20.08.2026 (последний
снапшот на физлот, premise_kind='квартира', не проданные):
в продаже 181 353
без area_pd 11 557 (6.4 %)
реально < 25 м² 7 013
Корзина «<25» состояла из неизвестного на 62 % и завышала долю мелких
лотов в блоке «По предложению (без темпа продаж)».
Зеркала у такого отображения не было: `layout_signature.area_bin`
принимает float и NULL-ветки не имеет вовсе, а velocity-MV по площади
не группирует — то есть `NULL → '<25'` было выдумкой, а не переносом
чужого правила.
Исключать такие лоты нельзя: они реально в продаже, и без них
предложение занизилось бы на 6.4 %. Поэтому отдельная корзина «н/д».
Медиана площади у неё выйдет NULL (PERCENTILE_CONT игнорирует NULL) —
честно. Схема не меняется: area_bin остаётся str, OpenAPI прежний.
Тест герметичный и прогоняет НАСТОЯЩИЙ SQL: временная таблица
objective_lots затеняет боевую в пределах сессии, запрос берётся из
модуля дословно, прод-данные не читаются.
Двусторонне: против origin/main корзины распределяются как
{'<25': 2, '25-40': 1, '40-60': 1} — конкретное неверное значение, ни
одного TypeError/ImportError. Контроли (сумма лотов сохраняется,
обычные корзины не меняются) зелёные с обеих сторон.
pytest backend/tests/sql/ — 38 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_extract_act_date` брал ПЕРВОЕ «от DD.MM.YYYY» во всём OCR-тексте.
«Сообщение о планируемом изъятии» открывается списком оснований, и первой
строкой там стоит
«Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1
«Об утверждении Генерального плана города»»
— Генплан, а не акт об изъятии. На проде это дало 11 строк из 27 с датой
2004-07-06 при проектах 2020 и 2022 годов, причём одну и ту же дату
получили ДВА разных документа (развязка на Сибирском тракте и улица
Энергостроителей). Совпадение даты у несвязанных актов и было первым
признаком, что дата не своя.
Дата принимается, только если в 120 символах перед ней стоит слово
«постановлени». Ссылки-помехи в этих документах — «Решение … Думы» и
«Приказ Министерства» — его не содержат. Окно шире самой фразы, потому
что OCR перемешивает колонки таблицы и вклинивает в неё чужой текст
(«…Администрации города документами) Екатеринбурга от 19.04.2019…»).
Если подходящей даты нет — None. Дата чужого документа хуже пустоты: по
ней нельзя ни отфильтровать актуальные изъятия, ни сверить срок, и она
неотличима от настоящей.
Калибровка не на одном образце: все пять исходных PDF загружены и
распознаны тем же трактом, что использует загрузчик (ocr_pdf_text в
прод-контейнере). Окна 80/120/160 дают одинаковые 5 из 5. Более узкое
правило (плюс «администраци») давало те же 5 из 5, но ломало законный
случай «Постановление № 509-ПП» — областной акт без слова «администрация»,
уже закреплённый тестом test_act_date_extracted_from_text; взято широкое.
Сквозная проверка: патченный код прогнан по всем пяти распознанным
текстам целиком — 27 записей, ровно столько же, сколько строк в
land_reservation; 11 меняют 2004-07-06 на настоящую дату, 16 не двигаются.
Двусторонне: против origin/main три теста красные с реальным неверным
значением ('2004-07-06'), ни одного TypeError — тесты идут через
extract_izyatie_records, чья сигнатура одинакова на обеих сторонах.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_detect_kind` проверял «резервир» первым и возвращал «резервирование»
безусловно. Постановление об изъятии, где резервирование упомянуто
вскользь — типовая формулировка «ранее зарезервированных земель», ссылка
на утративший силу акт, — классифицировалось как резервирование.
Ошибка не единичная: `kind` в `extract_reservations` один на весь
документ, поэтому неверный тип уходит в КАЖДУЮ строку land_reservation
по этому акту.
Побеждает то слово, что встретилось раньше. Тема документа стоит в
заголовке, поэтому позиция — сигнал сильнее порядка проверок, и он
симметричен: заголовок «О резервировании» так же выигрывает у «изъятия»
в теле. Простая смена порядка проверок этой симметрии не даёт — на неё
поставлен отдельный контроль.
Текущих ошибок на проде нет, и это измерено: в land_reservation 27
строк, все из источника izyatie_ekb_ocr, все «изъятие», ни в одной
выдержке слова «резервир» не встречается. Правка закрывает возможность,
а не чинит существующую порчу.
Двусторонне: против origin/main два теста красные с конкретным неверным
значением (`assert 'резервирование' == 'изъятие'`). Контроли —
симметрия заголовка, одиночные маркеры, откат к default_kind — зелёные с
обеих сторон. Формулировки в тестах взяты с прода дословно (basis_act).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Регекс статусов каталога ДОМ.РФ искал ключевые слова без защиты от
отрицания, поэтому русские отрицательные формы давали ОБРАТНЫЙ статус:
«нереализована» → содержит «реализована» → sold
«не продана» → содержит «продана» → sold
«не забронирована» → содержит «забронирована» → reserved
«не в продаже» → содержит «в продаже» → free
Свободная квартира попадала бы в domrf_kn_flats.status проданной.
Отрицание теперь гасит токен, а не переворачивает его. «Не забронирована»
не означает ни sold, ни free; «не продана → free» — это вывод, а не факт
со страницы. Лучше отсутствие статуса, чем неверный.
Заодно вылечен второй дефект того же места: разбор брал ПЕРВОЕ совпадение
в блоке, поэтому «Квартира не продана. Статус: в продаже» на main даёт
sold. Новый _status_in_text перебирает все вхождения и берёт первое
неотрицаемое — настоящий статус в блоке больше не теряется.
Текущий эффект на проде НУЛЕВОЙ, и это проверено, а не предположено:
catalog_updated_at пуст у всех 983 088 строк domrf_kn_flats (скрапер не
записал ни одной), таска scrape_kn_catalog_flats закомментирована в
beat_schedule.py из-за WAF-cooldown. Существующие значения status
(free 25 656 / sold 3 122 / booked 641) пришли из kn-API — среди них
'booked', которого нет в константах этого модуля. Правка
предупредительная: при включении пути дефект инвертировал бы статусы молча.
Двусторонне: против origin/main 7 тестов красные, каждый с конкретным
неверным значением («Нереализована» → 'sold'). Контроли (7 обычных форм
и «не» в хвосте слова «Цене») зелёные с обеих сторон. Старая сюита
парсера — 320 passed, регрессий нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`objective_scrape_runs` не подметалась ничем: `worker_ready` знал только
про `kn_scrape_runs` и `nspd_geo_jobs`. На проде 20.08.2026 в ней висело
6 строк `status='running'` с 17.05 — 94 суток, при 71 `done` и НИ ОДНОМ
`failed`. Отсутствие `failed` — след отравления сессии, из-за которого
`_finish_run(status='failed')` не мог записаться (причина починена
#2972). Причина устранена, но жёсткое убийство воркера (редеплой, OOM)
по-прежнему оставляет `running` навсегда: у Объектива нет ни своего
cleanup_zombies, ни снапшота для resume.
Тот же инвариант, что у kn: на worker_ready активных воркеров нет,
значит любая строка `running` осиротела. Resume не ставим —
возобновлять нечего.
`finished_at` ставится НЕ NOW(), а `COALESCE(heartbeat_at, started_at)`:
прогон, умерший 94 дня назад, не должен читаться как «завершён только
что». Монитору свежести это безразлично в обе стороны — `last_success_at`
и `recent_output` считаются только по `status='done'`, а
`last_attempt_at`/`last_status` — по `started_at`, так что зомби-строки
не попадают в него ни одним столбцом (проверено по коду
_FRESHNESS_SOURCES, а не предположено).
Двусторонне: против origin/main три теста красные по существу («не
трогает objective_scrape_runs», функция при этом отрабатывает 4 запроса
— то есть краснота не от отсутствующего символа). Мутационно проверены
оба контроля: снятие `WHERE status='running'` роняет
test_only_running_rows_are_touched, замена на `finished_at = NOW()`
роняет test_finished_at_is_last_sign_of_life_not_now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 тасок объявляли `max_retries=2`, но ретраи не реализовывали: ни
`autoretry_for` в декораторе, ни вызова `self.retry()` в теле. Celery в
таком виде параметр не применяет — при исключении таска падает с первой
попытки. Читающий код видит «до 3 попыток», а их одна.
Убран `max_retries` у: cbr_macro_sync, rosstat_macro_sync,
developer_registry_refresh, location_refresh, mv_sales_tracker_refresh,
refresh_analytics, refresh_layout_velocity, refresh_quarter_price_index,
scrape_objective.sync_objective_group, supply_layers_refresh,
scrape_kn.scrape_kn_region. Заодно убран `bind=True` там, где `self` не
использовался вовсе; в `scrape_kn_region` он оставлен — `self.request.id`
пишется в kn_scrape_log.
Не тронуты и не должны быть: `resume_kn_run` (max_retries=12 +
настоящий self.retry()), `nspd_sync`/`scrape_cadastre` (autoretry_for),
`nspd_geo`/`objective_etl` (max_retries=0 — честное «ретраев нет»).
Гейт `test_2464_retry_config_is_real.py` разбирает AST всех модулей
`app/workers/tasks/` и требует: если декоратор объявляет ненулевой
max_retries, в нём есть autoretry_for либо в теле функции есть
self.retry(). Три таски из одиннадцати гейт нашёл сверх списка эпика.
Проверка гейта: с фиксом зелено, при возврате `max_retries=2` в
supply_layers_refresh — красно с указанием на эту таску. Плюс два
контроля: гейт видит ≥20 тасок (не молчит из-за пустой выборки) и
признаёт обе законные формы ретраев.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_profile/update_profile делают «снять is_default у всех → поставить новому»
двумя отдельными операторами. Между ними инвариант нарушен, и при одновременных
запросах у пользователя может оказаться ДВА профиля с is_default=TRUE. А читающий
_SELECT_DEFAULT брал LIMIT 1 БЕЗ ORDER BY — выбор молча перескакивал между ними от
запроса к запросу.
Два рубежа, а не один:
миграция 190 — частичный уникальный индекс (user_id) WHERE is_default: два
дефолта становятся невозможными на уровне БД;
ORDER BY id — детерминированный выбор, если индекс когда-нибудь снимут.
Соседние запросы этого файла тай-брейк по id уже имеют.
Индекс не мешает штатной переустановке дефолта: порядок операторов в коде уже
правильный (сначала снять у всех, потом поставить), поэтому в момент проверки
дефолтов ноль. Это отдельно проверено тестом.
Безопасность миграции: на проде нарушений нет — у admin один дефолт, у __system__
ноль, ни одного пользователя с двумя. Таблица в 4 строки, индексируется мгновенно.
lock_timeout проставлен по #2752.
Тест проверяет ПОВЕДЕНИЕ на живом Postgres: вторая установка дефолта отвергается
базой. Плюс фальсификация — без индекса два дефолта вставляются молча; без неё
зелёный тест неотличим от «оно и так не вставлялось». Плюс два контроля:
переустановка дефолта работает, разные пользователи сохраняют свои.
Тест про ORDER BY вынесен в tests/services/site_finder, а НЕ внесён в
skip_allowlist: живой БД он не требует, и пропускаться вместе с DB-тестами ему
незачем. Против origin/main он краснеет, показывая запрос без тай-брейка.
Прогоны: без БД — 648 passed rc=0; с БД — 5 passed rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Докстринг _resume_zombie_runs формулирует инвариант прямо:
No time threshold: by definition, on worker_ready ANY 'running' row is a zombie
because there is no active worker. Previously we required heartbeat … stayed in
'running' status forever and required manual cancel/resume.
А запрос добавлял `AND objects_snapshot IS NOT NULL`. Строка без снапшота в выборку
не попадала и оставалась 'running' НАВСЕГДА — ровно то состояние, ради устранения
которого функция и заводилась.
Снапшот нужен, но не для пометки, а для ВОЗОБНОВЛЕНИЯ: resume_kn_run восстанавливает
обход «using objects_snapshot» и без него упал бы. Поэтому зомби помечаются все, а
resume ставится только тем, кого есть чем возобновить; остальные получают честную
причину в error вместо тишины.
Про тест — отдельно, потому что первая версия была негодной. Двойник сессии отдавал
строки независимо от WHERE, и на origin/main главный тест («строка не помечена»)
ПРОХОДИЛ, а краснели два других — по ложной причине. Научил двойник соблюдать ровно
тот фильтр, о котором спор, и сузил совпадение до `AND objects_snapshot IS NOT NULL`:
правка выносит то же выражение в список полей SELECT, и совпадение по голой подстроке
отсекало бы строки у исправленной версии тоже.
Против origin/main теперь:
строка без снапшота не помечена zombie → падает (UPDATE вообще не выполняется)
в смешанной выборке помечены не все → падает: {1,3} вместо {1,2,3}
невозобновляемому resume не ставится — контроль, зелёный с обеих сторон
возобновляемый получает resume как раньше — контроль, зелёный с обеих сторон
Первый контроль ловит «починку», ставящую resume всем подряд.
Замер прода 20.08: строк в 'running' сейчас нет, то есть правка предотвращает, а не
чинит. Из 20 исторических 'zombie' восемь — без objects_snapshot, так что случай
не гипотетический.
Прогоны: tests/workers rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Деплой делает `git reset --hard origin/main`, поэтому любой site-блок,
вписанный руками в /opt/gendesign/Caddyfile, живёт до первого пуша в main.
16.08 так и вышло с garmin.gendsgn.ru: контейнер остался healthy, а хост
исчез из конфига — TLS-хендшейк стал падать `alert internal error`,
потому что сертификат для него Caddy больше не держит.
Класть такой блок в git нельзя: у него единственный рубеж аутентификации —
секрет в пути URL (апстрим-MCP аутентификации не имеет, а claude.ai
connector кастомные заголовки не шлёт).
Развязка: в репозитории только `import caddy/local/*.caddy` и сам каталог
(держится .gitignore'ом), файлы с секретами лежат на VPS untracked —
reset --hard их не трогает. Пустой glob для Caddy не ошибка,
`caddy validate` проходит, на машинах без локальных блоков import — no-op.
Проверено `caddy validate` 2.11.4 на VPS: и с пустым каталогом, и с
реальным блоком garmin — Valid configuration.
Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.
Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).
- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
(иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
= False внутри replay_fixture (было только в самом гейте) — иначе штатная
регенерация baseline (--from-fixture --update-baseline) писала baseline,
который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.
Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене.
## 1. Город предлагали выбрать, но отвечать по нему не умели
Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия
(`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в
двух местах. Они разошлись в обе стороны:
предлагали, но не отвечали: Серов
отвечали, но не предлагали: Берёзовский, Среднеуральск, Ревда
Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал:
«Этот адрес вне области, по которой мы собираем данные.
Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько
городов вокруг.»
Про город в той же самой области. Серов при этом покрыт данными: 363 активных
объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в
жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт.
Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением
7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест
есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс
проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый,
город по координатам не резолвится.
## 2. Подсказки не слушались выбранного города
`city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только
екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир
пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает.
Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н,
пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом —
ровно баг #2576, ради которого город и спрашивают.
Публичная ручка теперь подставляет город в саму строку запроса. Проверено на
проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга
подстановка безвредна — три разных адреса дали тот же результат с префиксом и
без, поэтому правило одно на все города, без исключения для основного трафика.
Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит
поведение закрытого контура (`target_city_ambiguous`).
## Фикстура теста
`_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от
всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до
ближайшего центроида.
## Мутации
убрать Серов из покрытия (состояние прода) → падает сверка списков
не подставлять город в строку → падает проверка ручки
откат → 21 passed
Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard.
`city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков
делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы
не запускала — то есть ровно тот путь, которым списки и разошлись.
Замер на проде сразу после выкатки #2913: страница `/estimate` отдаёт 200 и
выглядит правильно, но после гидратации монтируется `GuardedRoute` —
404 /trade-in/api/v1/me ×3
404 /trade-in/api/v1/trade-in/support/anon/unread
плюс виджет чата поддержки из закрытого контура и экран «Не удалось проверить
доступ» внизу страницы. Форма при этом переставала работать ВОВСЕ: разметка с
сервера видна, поле принимает текст, а обработчики не подключены — человек
печатает адрес и не получает ни подсказок, ни ошибки. Проверено, что дело не в
API: ручной `fetch` из той же страницы отдаёт 200 и восемь подсказок.
Причина ровно та же, что чинил PR #2910 для `/`: список публичных путей в
гварде и матчер `@meraPages` в Caddyfile — одно множество, записанное в двух
местах. Тогда разошлись на `/`, теперь на `/estimate`. Оба раза страница
отдавала 200, ломалась только в браузере, и ни один тест этого не видел.
Комментарий в шапке гварда инцидент не предотвратил (он там был), поэтому
совпадение теперь запинено ТЕСТОМ: он читает Caddyfile и требует, чтобы каждый
путь из `@meraPages` проходил `isPublicPath`. Сверяемся с источником, а не
дублируем список третий раз.
Мутация — вернуть список к состоянию, которое уехало на прод:
2 failed / 3 passed
× пропускает короткие адреса публичного домена
× совпадает с тем, что публичный домен реально раздаёт (Caddyfile)
откат → 5 passed.
Плюс vitest 56 passed, tsc, lint, isolation guard 41 файл.
`Caddyfile` уже в paths-фильтре фронтового лэйна (#2913), так что правка одного
лишь конфига теперь тоже гоняет этот гейт.
Первая версия шага смонтировала `$PWD` внутрь caddy-контейнера и упала на
`open /etc/caddy/Caddyfile: no such file or directory`.
Причина: job сам исполняется внутри контейнера, а `docker run` создаёт
КОНТЕЙНЕР-БРАТ на том же демоне. Путь в `-v` резолвится на ХОСТЕ, тогда как
`$PWD` — путь внутри job-контейнера, которого на хосте нет. Классическая
ловушка docker-in-docker, и она не зависит от содержимого конфига — смонтируй
так что угодно, монтирования просто не произойдёт.
Заменено на `docker create -w /work` + `docker cp` + `docker start -a`: копия
не зависит от того, как смонтирован workspace. Копируется и каталог `caddy/` —
Caddyfile делает `import caddy/users.caddy.snippet`, без него validate падает
на импорте.
Проверено локально обе стороны: валидный конфиг → `Valid configuration`,
exit 0; конфиг с незакрытой скобкой → `unexpected EOF`, exit 1. Без второй
проверки гейт мог бы оказаться вечно-зелёным.
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.
## Caddy: открытый редирект и потерянные ссылки
Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.
Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).
Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).
`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.
## Гейты, которых не было
`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.
Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.
## Бюджет внешнего геокодера
Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.
Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.
## «Адрес нигде не сохраняется» — теперь правда целиком
Две утечки, обе воспроизведены:
1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
вызов, прод пишет stdout в persistent journald — адрес ложился на диск
рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
время публичного запроса (contextvar, переживает await и to_thread).
Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
Закрыто `scrub_public_address`.
Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.
## Фронт
- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
не наступала вовсе, если человек переставал печатать: ответ по старой строке
долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.
## Проверено
Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.
Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
Проверка на живом Caddy (локальный стенд: реальный site-блок + заглушка вместо
фронта) показала, что 301 не работал вовсе: длинные адреса отдавали пустой
200. Причина — разбор Caddyfile: первый аргумент директивы, начинающийся со
слэша, читается как inline path-matcher. То есть `redir / permanent` означает
«для пути / редиректить на permanent», а не «редиректить на /»; матчер не
совпадал, директива молча не срабатывала, наружу уходило пустое тело.
Пустая страница с кодом 200 хуже 404: выглядит рабочей и не ловится ни
смоуком по коду ответа, ни глазом.
Замер после правки (`redir * <куда>`):
/trade-in/mera-public 301 → /
/trade-in/mera-public/ 301 → /
/trade-in/mera-public/oferta 301 → /oferta
/trade-in/mera-public/v3 301 → /v3
/ 200 upstream /trade-in/mera-public
/estimate 200 upstream /trade-in/mera-public/estimate
/trade-in/api/public/mera/coverage 200 upstream /api/public/mera/coverage
/trade-in/api/v1/me 404
/trade-in/v2 404
Заодно захват сужен до `([^/].*)` — страховка от протокол-относительной цели
(`//evil.example` резолвится браузером как чужой хост). Тем же замером
проверено, что сегодня это недостижимо и без ограничения: Caddy нормализует
путь до матчинга, и `//evil.example` / `/%2Fevil.example` приезжают одним
слэшем. Ограничение оставлено как второй барьер, комментарий говорит ровно
это — а не то, что чинилась живая дыра.
## Экран проверки — /estimate
Проверка квартиры вынесена на собственный адрес: там у автокомплита есть
место под список подсказок, а у результата — место рядом с полями. Форма в
герое лэндинга осталась входной точкой и уводит сюда, донося набранное через
sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом
с IP посетителя, а мы на той же странице обещаем ничего не хранить).
Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и
сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт
платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и
покрыты тестами: подпись под возрастом обязана говорить «объявление», а не
«продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет
вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что
поменять, — директива «никогда не блокировать вывод».
## Короткие адреса
Человек больше не видит /trade-in/mera-public/... — только /, /estimate,
/oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы
один канонический адрес, старые ссылки живы.
Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт
этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru.
Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и
href="/estimate" уехал бы на несуществующий /trade-in/estimate.
## Три дефекта, найденных на живом сайте
1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна
--b2c-* переменная не объявлялась, каскад молча пропускал такие декларации —
лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест:
каждая использованная в CSS переменная обязана быть объявлена.
2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой.
Ровно туда вела «Главная» в подвале.
3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес
B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря,
которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны.
## Периметр и приватность
Подсказки переведены на POST: access-лог публичного домена пишет URI целиком,
то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает.
Метод запинен тестом — это часть обещания, а не стиль.
П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что
адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно —
передаётся, используется однократно, в базах не сохраняется. Последнее
проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит
пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до
платного шага (бесплатная проба не хранит ничего, платный расчёт хранит).
## Проверено
vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard
40 файлов, backend 75 passed, caddy validate = Valid configuration.
Мутации структурных гейтов:
убрать --b2c-accent-text из b2cVars → падает тест палитры
убрать /estimate из @meraPages → падает тест маршрутов
добавить импорт next/link → падает тест basePath
откат → 14 passed
Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов,
и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.
Refs #2894, #2895
main принёс PR #2751 (ruff-шаг в ci-tradein.yml/backend-tests) параллельно с
fetch-depth: 0 из этой ветки в том же job'е — не противоречат друг другу,
слились автоматически.
Единственное ручное разрешение — _manifest_applied.txt (modify/delete):
main дописал файл, ветка его удаляет. Разрешение — удаление, это и есть
предмет PR: гейт номеров миграций берёт эталон из git (origin/main), а не
из ручного манифеста, который отставал и по построению не мог покраснеть
(#2683, живой инцидент 15.08 — коллизия 264_ между двумя независимыми ветками).
Мониторинг GlitchTip сейчас нем (alerts_projectalert/alerts_alertrecipient
пусты, EMAIL_URL=consolemail:// печатает письма в stdout, аудит на проде
2026-08-15). GlitchTip умеет получателя типа webhook, но шлёт свой Slack-
совместимый JSON без каких-либо заголовков — Telegram Bot API его не
понимает, нужен адаптер.
- app/api/v1/glitchtip.py: POST /api/v1/trade-in/ops/glitchtip-webhook —
принимает issue- и uptime-алерты (структурно одинаковый payload у
GlitchTip 6.1.6, см. docstring), форматирует короткое сообщение
(проект/заголовок/ссылка/время получения) и шлёт через существующий
TelegramClient в отдельную тему алертов. Обрезка под лимит Telegram
(4096 симв.), неизвестная форма payload пересылается как есть с
пометкой вместо 500.
- Auth: GlitchTip не может слать кастомные заголовки (aiohttp.post без
headers=) — переиспользуем TRADEIN_INTERNAL_AUTH_SECRET (#2213) как
query-параметр `secret`, constant-time compare. В отличие от rbac.py
пустой секрет здесь fail-CLOSED (503), это единственный auth-рубеж пути.
- config.py: TELEGRAM_ALERTS_CHAT_ID / TELEGRAM_ALERTS_TOPIC_ID — намеренно
отдельные от TELEGRAM_SUPPORT_*, чтобы алерты не лились в топик клиентов.
- rbac.py: путь добавлен в _PUBLIC_PATHS (фиксированный, без секрета в
самом пути — секрет только в query).
- docker-compose.prod.yml: glitchtip-worker (реально шлёт вебхуки, не
glitchtip-web) переведён на networks: [default, shared] — без этого
tradein-backend не резолвится с его стороны (общей сети не было вообще).
EMAIL_URL был захардкожен как consolemail:// — письма печатались в stdout
и никуда не уходили. Теперь адрес приходит из файла окружения на сервере
(GLITCHTIP_EMAIL_URL), пароль почтового ящика в репозиторий не попадает.
Главное здесь не web, а worker: письма и веб-хуки шлёт celery, а у
glitchtip-worker почтовых переменных не было вовсе. Настройка одного
web-сервиса не дала бы ни одного отправленного письма.
Схема DSN зафиксирована комментарием: для 465 нужен smtp+ssl://, а не
smtps:// — вторая помечена deprecated и включает STARTTLS, с которым
Beget рвёт соединение на 465.
Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает
собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого
контура.
## Отдельный префикс, а не проброс кусков /api/v1/*
На meraocenka.ru действует allowlist-by-default. Открыть там API можно было
двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под
которым по определению не лежит ничего закрытого. Выбран второй: при первом
одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает
наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена
ошибки, а не удобство.
Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в
app/api/public/. Случайно нельзя.
## Ноль записей в БД
Обе ручки только читают: /coverage — один SELECT, /suggest — прокси
автокомплита. Это условие, при котором публичная форма работает ДО контура
согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates
раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать
будет, открывается только вместе с ним.
## Делегирование, а не копии
Обе ручки вызывают те же функции, что обслуживают закрытый контур
(v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894
показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там,
где платный расчёт видит ноль. Публичный ответ переиспользует
CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля».
## Бюджеты
Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз
стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом
тратит единицы на адрес), 15/мин на пробу.
## Проверено
11 тестов, из них структурные: набор ручек под /api/public проверяется на
РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с
rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака.
Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково
зелёный и когда исключение точечное, и когда auth-гейт снят целиком.
Мутации:
убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed
снять бюджет с /coverage → 2 failed / 9 passed
откат → 11 passed
Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid
configuration, ruff чист.
Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки
отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404.
Зелёная только первая проверка = API открыт целиком, а тест этого не заметил.
Refs #2894, #2895
Слияние main принесло собственные Sentry-скрубберы (redact_telegram_bot_token +
stabilize_retry_error_fingerprint) в app/main.py и app/scheduler_main.py — конфликт
разрешён композицией, а не выбором стороны: обработчик перед отправкой в GlitchTip
теперь прогоняет событие через всю цепочку в указанном порядке:
scrub_payment_request_body → scrub_pii_event → redact_telegram_bot_token →
stabilize_retry_error_fingerprint (main.py), и без redact_telegram_bot_token в
scheduler_main.py (тот процесс не держит TelegramClient) — оба канала,
before_send и before_send_transaction, используют один и тот же обработчик.
tests/test_sentry_scrub.py: тесты обеих сторон объединены без потерь — PR-D2
платёжный composed-тест (body-wipe + PII-scrub + token-redaction) и весь блок
RetryError fingerprint-стабилизации из main сосуществуют в одном файле.
Слить актуальный main (билд-раннер #2841/#2869, невалидные индексы #2752,
честный health-check и deploy-status #2841) в ветку очистки CI. Один конфликт
в .forgejo/workflows/deploy.yml: список triggers.paths — main добавил
ops/docker-prune.sh (#2887), ветка добавила auth/** (RBAC roles config).
Разрешено сохранением обоих путей, без потери ни одного триггера.
Раньше listing_segment писался ТОЛЬКО при INSERT в save_listings — строка,
единожды родившаяся с NULL-сегментом (классификатор не сработал на первом
скрейпе), никогда не получала сегмент на повторных сборах, даже когда он
становился определим. Симптом лечила отдельная джоба деактивации
(deactivate_stale_{cian,yandex}_null_segment, миграция 266, PR #2908) — чистит
мусор, но не саму запись.
Добавлен listing_segment = COALESCE(EXCLUDED.listing_segment,
listings.listing_segment) в ON CONFLICT DO UPDATE, и тот же идиом в
reconcile-UPDATE (dedup_hash-drift fallback path) — иначе self-heal был бы
неполным для строк, прошедших через этот путь. COALESCE, а не голый EXCLUDED,
чтобы повторный скрейп без определённого сегмента не затирал уже известное
значение пустым (тот же паттерн, что уже применён для city/kitchen/ceiling).
Тесты: tests/test_listing_segment_upsert_selfheal.py — INSERT-путь,
ON CONFLICT COALESCE (обе стороны), reconcile-UPDATE COALESCE.
Три независимые правки инфраструктурной гигиены прод-VPS, замеренные живьём
2026-08-15 (ssh gendesign, read-only).
1. ops/docker-prune.sh: новая секция снимает зависшие (running, но брошенные)
job-контейнеры Forgejo Actions раннера — три штуки Up 4-8 недель на проде,
docker container prune их не видит (фильтрует только status=exited).
Фильтр по имени — якорь ^FORGEJO-ACTIONS-TASK- (regex, не substring),
сервисные контейнеры (forgejo/forgejo-runner*/gendesign-*/tradein-*/couchdb)
под него не подпадают + explicit-skip как страховка. Возраст — из
docker inspect .State.StartedAt, порог JOB_CONTAINER_MAX_AGE_HOURS (default
24 — CI job столько никогда не идёт). DRY_RUN уже существующий флаг,
переиспользован. Идемпотентно на пустом списке (mapfile + `|| true`).
2. ops/journald-gendesign.conf.example: SystemMaxUse=500M для journald.
Замер: journalctl --disk-usage без прав группы systemd-journal/adm
недосчитывает (показал 174M) — реальный du -sh /var/log/journal = 2.5G,
~80% всех 3.1G /var/log. journald.conf на проде сейчас без лимита вообще.
Установка — руками на сервере (systemd-конфиг вне /opt/gendesign, deploy.yml
его не синкает): инструкция в шапке файла.
3. Caddyfile: убран мёртвый блок status.gendsgn.ru (указывал на
несуществующий uptime-kuma, дёргал ACME раз в 6 часов на staging-эндпоинте).
Заодно убрана dangling forward-ссылка "описан для status.gendsgn.ru ниже" в
комментарии у meraocenka.ru. grep подтвердил: других ссылок на этот хост в
Caddyfile/compose/скриптах не осталось (docker-compose.uptime.yml и README
упоминают — вне scope, стек сам по себе не трогается). caddy validate через
prod-контейнер (stdin, без записи файлов на диск) — конфиг валиден, как и
baseline до правки.
На meraocenka.ru посетитель видел внизу страницы экран проверки доступа из
закрытого контура. Замер на проде: три вызова GET /trade-in/api/v1/me → 404
плюс подтянутый следом клиент чата поддержки.
Причина. Caddy на коротком домене переписывает путь ВНУТРЕННЕ (rewrite, не
redir): посетитель остаётся на meraocenka.ru/, а Next получает
/trade-in/mera-public. Поэтому SSR видит /mera-public и bypass срабатывает, а
после гидратации usePathname() возвращает «/» — путь из адресной строки — и
bypass отваливается. Монтируется GuardedRoute, летит запрос к /me, а на этом
хосте Caddy намеренно не проксирует /trade-in/api/* → 404 → NoAccessScreen.
В шапке RouteGuard прямо стояло допущение «домен/rewrite настраиваются
отдельным PR — на поведение гварда это не влияет, он смотрит на путь». Оно
сломалось ровно в тот момент, когда домен появился; заменено разбором факта.
Правка: четыре коротких адреса публичного домена (/, /oferta, /refund,
/privacy) добавлены в bypass ТОЧНЫМ совпадением, отдельной веткой. Префиксную
проверку для «/» применять нельзя — startsWith("/") истинно для любого пути,
то есть один такой элемент сделал бы публичным всё приложение. Эти адреса
существуют только на публичном домене: на gendsgn.ru их ловит catch-all 404, а
«/» отдаёт server-side redirect на /v2 из next.config, то есть приложение по
этому пути не рендерится вовсе.
Тест на оба инварианта: короткие адреса публичны И закрытый контур (/v2,
/admin/*, /history, /scrapers/*, /login) — нет. Мутация «точное совпадение →
префиксное» роняет два теста из четырёх, проверено прогоном.
Проверено: vitest 33 passed (7 файлов), tsc --noEmit, isolation guard 31 файл.
Повторная проверка /coverage закрыла оба MAJOR из #2894, но выявила три
новых дефекта:
1. Город больше не резолвится из моды listings.city найденной когорты —
эта колонка хранит город SWEEP-контекста скрейпера (миграция 196), не
геокод адреса объявления. Замер на проде: 90/90 строк в радиусе 1000м
вокруг Берёзовского имеют city='Екатеринбург', 74/74 вокруг Ревды —
city='Первоуральск'. Города-спутники из COVERAGE_GREEN/YELLOW_CITIES были
физически недостижимы. Город теперь резолвится детерминированно по
lat/lon запроса — ближайший центроид из статичной константы (8 городов,
рядом с ручкой, не в БД — comment объясняет почему) в пределах 25 км.
city_hint остаётся в схеме (фронт его шлёт для соседних ручек), но чисто
информационный — на порог/статус не влияет.
2. test_max_age_outlier_days_passed_to_sql проверял подстроку, которая
встречается в SQL дважды (count и percentile_cont) — мутация «убрать
FILTER у percentile_cont, оставив у count» проходила зелёной. Добавлен
живой поведенческий тест (вставляет когорту + выброс days_on_market=4000,
проверяет что медиана не сдвигается) — ловит эту мутацию (подтверждено:
median 8→9 при мутации).
3. _live_session() вызывался в pytest.mark.skipif на этапе сбора тестов и
создавал никогда не закрываемый Session, плюс дублировался в теле теста.
Заменено на _live_db_available() (open+close голого connection) для
skipif и pytest-фикстуру live_session с гарантированным close/dispose.
4. Nit: пустая когорта в поддерживаемом городе отдавала status=not_covered
вместе с ненулевым threshold — противоречило докстрингу
CoverageProbeResponse.threshold ("0, когда порог неприменим"). threshold
теперь всегда 0 при not_covered, независимо от причины.
fix/tradein-ttl-effective-cap (PR #2907, merged into main while this branch was in
flight) already claimed 264/265 for deactivate_stale_avito_cap_mult /
deactivate_stale_yandex_cap_mult. Renumbers this branch's
264_seed_deactivate_stale_null_segment_yandex_cian.sql -> 266_seed_... (git mv +
_manifest_applied.txt entry moved after 264/265 + self-references in the migration
header and in test_deactivate_stale_listings.py's _MIGRATION_264 constant/test names).
Merges main's bool-guard (ttl_days/cap_mult reject bool) + CAP_MULT ceiling +
per-source cap_mult calibration with this branch's null_segment_only kwarg
(explicit `listing_segment IS NULL` predicate, since ANY(:segments) never matches
NULL) -- both features apply to the same deactivate_stale_listings() call site in
product_handlers.py and the same function signature/docstring in
deactivate_stale_avito.py, so every conflict was signature/docstring-level, not
logic-level (git already auto-merged the function body correctly since the two
features touch disjoint lines below the signature). Also reconciled the module
docstring's "known gap" note (main) to reflect that the NULL-segment slice it
measured (cian 211 / yandex 523 rows >60d) is now closed by this migration --
novostroyki stays open, unrelated to this branch.
В шапке модуля, в комментарии миграции 264 и в докстринге теста стояло «23 687 из
44 744 avito-объявлений не подтверждались >7 суток» под заголовком «ЗАМЕР НА ПРОДЕ».
Число реальное, но приписано не тому. Перепроверено запросом 2026-08-15:
это ВСЕ источники вместе, и две трети — новостройки, которых оценщик не берёт
(он фильтрует listing_segment IS NULL OR = 'vtorichka'). У самого avito
просроченных строк ноль: 8 663 активных, максимальный возраст 10 суток.
Оставлять это в коде нельзя: следующий человек прочитает «avito раздут вдвое»,
проверит и не найдёт — а заодно потеряет доверие к остальным числам в том же
абзаце, которые верны и сверены с scrape_runs.counters.
Заодно явно записано, чего потолок НЕ делает: он не сжимает пул (0 деактиваций
замерено на всех четырёх джобах), а защищает от разгона пола и от опечатки в
расписании. Настоящий раздутый срез — строки с пустым сегментом, они чинятся
отдельной джобой.
Independent review found two MAJOR defects in POST /api/v1/trade-in/coverage:
MAJOR-1: the probe cohort WHERE clause was missing three predicates present
in estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision !=
'city', price_rub > 0) — the free probe could answer "ok" at points where
the paid estimator's own 1000m radius tier sees zero real analogs. Prod
example: 56.868904/60.837955, 2 rooms, 50 m2 gave n_listings=22/status=ok
while the estimator's cohort at the same radius was 0 (all 54 rows were
novostroyki). Added the three predicates verbatim from estimator.py, plus
both a static SQL-text regression test and a real-Postgres integration test
(skip_allowlist.txt, same _live_session() pattern as test_gar_flats_loader)
that inserts novostroyka/geo_precision=city/price=0 rows and asserts they
are not counted.
MAJOR-2: median_listing_age_days was computed from days_on_market, which on
prod is populated almost exclusively by one source (yandex) — thin cohorts
produced a "median" over 1-2 listings. Added n_with_age to the response
(honest count of listings the median is based on); median is now null below
COVERAGE_MIN_AGE_SAMPLES=5, and values above COVERAGE_MAX_AGE_DAYS=365 (near
-certainly dead listings, per prod: 15% of fresh yandex rows exceed 365d,
max 4261d) are excluded as outliers before the percentile is computed.
MINOR: city_hint was trusted at face value and echoed back verbatim — a
client could pass city_hint="Екатеринбург" with coordinates in Серов and get
threshold=8/status=ok. _resolve_coverage_city now prioritizes the SQL
cohort's mode city (ground truth) over the client hint, falling back to hint
only when the cohort is empty (where status is forced not_covered anyway).
Unmatched cities no longer echo the raw client string in the city field.
544 yandex + 224 cian active rows carry listing_segment=NULL (legacy rows
predating migration 011, plus a small trickle that can never self-heal since
the upsert ON CONFLICT never rewrites listing_segment on re-scrape). 94-97%
of them are frozen at ~86 days old, yet the estimator's Tier A (same-building)
and Tier C (micro-radius) anchor queries filter only is_active=true -- no
freshness column -- so these stale asking prices anchor live valuations.
deactivate_stale_yandex/_cian (migration 115) already run at TTL=30 but scope
segments=['vtorichka'] only: `= ANY(CAST(:segments AS text[]))` never matches
NULL, so the NULL bucket was invisible to both existing jobs and to
avito/domklik/n1 (which are source-blanket or vtorichka-only respectively).
Adds null_segment_only kwarg to deactivate_stale_listings() building an
explicit `listing_segment IS NULL` predicate (confirmations/revisit-floor
builders extended in parallel for correctness, though both gates are kept
off for this slice -- population too small for thresholds calibrated on a
full vtorichka sweep, would permanently skip as unhealthy). Two new
scrape_schedules rows (migration 264) run it per source, untouched
novostroyki/vtorichka jobs unaffected.
TTL=60d (vs 30d for vtorichka): revisit-floor is not computable here (rows
out of dedicated sweep scope have no revisit-gap history), so the margin is
folded into the TTL directly -- 2.26x/1.4x over the measured p99 revisit gaps
already on file for cian/yandex vtorichka (26.6d/43.0d). Bimodal age
distribution means this costs almost nothing in coverage (30d vs 60d: 744 vs
734 deactivated).
First run: 734 of 768 NULL rows deactivated (211 cian, 523 yandex), 34 remain
(fresher than 60d, still incidentally re-touched). Comparable pool
(is_active AND segment IN (NULL, vtorichka)) after: cian -2.7% (7739->7528),
yandex -10.2% (5133->4610) -- yandex crosses the 10% flag threshold. All 734
removed rows already had scraped_at frozen >60d, i.e. already excluded from
Tier S/H (which do filter freshness, 14-60d window) -- the drop is real for
is_active headcount but zero-impact there; it only prunes Tier A/C, where it
removes stale prices rather than live comps.
Separate finding (not fixed here): base.py's ON CONFLICT DO UPDATE omits
listing_segment from SET entirely, so a legacy NULL row can never heal even
though cian/yandex SERP always compute segment deterministically on re-scrape.
Три остатка ревью TTL-CAP:
1. cap_mult < 1 пропускал bool: jsonb true -> True < 1 ложно -> потолок =
ttl_days*True = ttl_days -> пол молча отключается без ValueError. Тот же
класс дыры возможен и через ttl_days=true (TTL молча = 1). Оба параметра
теперь явно отклоняют bool ДО числового сравнения; воспроизведено на HEAD
и закрыто тестами (True/False на обоих параметрах).
2. test_avito_prod_floor_is_capped_by_calibrated_cap_mult хардкодил cap_mult=6
как вход -- мутация миграции 264 (6 -> 2) оставляла набор зелёным. Тест
теперь читает cap_mult ИЗ ФАЙЛА миграции regex'ом, ожидаемый результат
(потолок 60) остаётся зафиксированным числом -- дрейф калибровки в SQL
теперь ломает тест.
3. Текст миграции 264 утверждал "yandex 43.0 -> потолок 60, запас есть" по
статическому p99. Живые полы из scrape_runs.counters (08-10..08-15:
75/75/75/39/52/54) и live-замер сегодня (79.2, n=1961) выше потолка 60 --
тот же false-kill класс, что у avito. Откалибровал yandex отдельной
миграцией 265 (cap_mult=3 -> потолок 90, тот же запас ~14%, что у avito),
поправил таблицу в 264 на живые числа и пиннящий тест по образцу avito.
Численный эффект (live-замер 2026-08-15, до и после): next-run deactivated=0
на всех четырёх джобах что до, что после -- ветка по-прежнему НЕ сжимает пул
(avito/cian: живой пол уже ниже потолка, cap не участвует; yandex: 0 активных
строк старше 39 суток вообще, калибровка убирает будущий риск, не текущее
число; domklik: блокирован гейтом здоровья, confirmations 94 < 200). Ветка
остаётся тем, чем и была: защита от опечатки в расписании + калибровка, не
сжатие пула.
4508 backend-тестов зелёные (uv run pytest tests/), ruff чист на изменённых
файлах.
POST /api/v1/trade-in/coverage — до оплаты пользователь видит только n похожих
объявлений в радиусе 1000м и медианный возраст листинга, без единой цены.
Один SQL (радиус GIST + rooms + area ±15% + freshness 14д + тот же дедуп/cap-
канон, что у estimator._fetch_analogs), ноль внешних вызовов, ноль записей.
Пороги ok/thin/not_covered — константы рядом с ручкой (зелёные города >=8,
жёлтые >=12, остальные всегда not_covered). Поле median_listing_age_days
(не "срок продажи" — возраст активного объявления, цензурированная выборка).
RBAC не тронут — путь остаётся закрытым, открытие анонимного периметра
вынесено в #2895.
Ревью round 1 подтвердил basic_auth-часть, но нашёл 4 факта в RetryError-части:
1. reraise=True в geocoder.py не убирает шум, а переименовывает: наружу летит
httpx.HTTPStatusError, чей str() содержит ПОЛНЫЙ request URL с query string
(`for url '...search?q=<адрес>&...'`) — воспроизведено эмпирически. Тот же
per-address issue-explosion, просто под другим типом исключения.
Фикс: _HTTPX_ERROR_URL_QUERY_RE в sentry_scrub.scrub_pii_event режет query
string из httpx-style "for url '...'" сообщений — стабилизирует ТЕКСТ, не
только тип, независимо от того, уважает ли GlitchTip fingerprint-поле.
2. stabilize_retry_error_fingerprint затирал fingerprint целиком по (типу
причины) — RetryError из НЕСВЯЗАННЫХ подсистем с одинаковым типом причины
схлопнулись бы в один issue (geocoder vs scraper_kit оба ловят httpx-типы).
Фикс: culprit = event["logger"] (LoggingIntegration ставит его = имя
модуля-источника logger.exception) идёт первым компонентом fingerprint —
разные подсистемы больше не сливаются.
3. Второй живой источник RetryError, пропущенный round 1 (грепали литерал
"RetryError", не producers): BaseScraper._http_get в packages/scraper-kit —
@retry БЕЗ reraise=True, живой путь через YandexDetailScraper.fetch_detail
(yandex/serp.py и valuation.py переопределяют _http_get без retry — не
затронуты). Оставлен на fingerprint-хук намеренно: detail-URL варьируются
в ПУТИ (offer id), не в query — _HTTPX_ERROR_URL_QUERY_RE их не покрывает,
а добавление reraise=True туда воспроизвело бы ту же проблему через
HTTPStatusError с variable path вместо query.
4. type(exc).__name__ == "RetryError" (string-compare) → isinstance(exc,
RetryError) с прямым импортом tenacity.RetryError — не матчит посторонние
классы с тем же __name__, не промахивается мимо подклассов.
Полный backend suite (4479 passed, 21 skipped) + geocoder/scheduler/alerts
подмножества — без регрессий (reraise=True уже влит в main).
Не тронуто (вне scope round 2, подтверждено ревьюером как верное):
ops/glitchtip-auth-forwarder/* (basic_auth 401 дроп), массовая чистка
накопленных issue.
Round-2 review (MAJOR) left three items open:
1. cap_mult was threaded through as a jsonb default_params parameter but never
validated, reproducing the exact ttl_days<=0 hole the earlier guard closed.
Verified live: cap_mult=0 -> effective_ttl=0 -> whole active pool of the
source would deactivate; cap_mult=0.5 pushes the ceiling BELOW the operator-
configured ttl_days. Added `if cap_mult < 1: raise ValueError` next to the
ttl_days guard (same fail-fast contract, before any SQL). Non-numeric values
(e.g. a stringly-typed "6" from a typo in default_params) already fail safe
via TypeError on the comparison, caught by the same except-block -> mark_failed.
Covered with 5 new tests (zero/negative/<1/non-numeric/mark_failed routing).
2. The mechanical part of cap_mult (parameter + wiring) was merged but never
calibrated for avito on prod -- no migration shipped, so prod default_params
for deactivate_stale_avito still lacked "cap_mult" and ran with the module
default (CAP_MULT=2, ceiling=20d), which is BELOW avito's own p99 revisit gap
(42.1d) and below the observed prod peak (floor=52, three runs 08-10..08-12).
Added data/sql/264_deactivate_stale_avito_cap_mult.sql (idempotent, same
pattern as 219) setting cap_mult=6 for deactivate_stale_avito only (ceiling
60d, matching the order of magnitude already used for cian/yandex). cian/
yandex/domklik keep the CAP_MULT=2 default -- their p99 gaps (26.6/43.0/3.1)
sit comfortably under their default ceilings (60/60/28), no override needed.
Pinned the calibration with a dedicated test
(test_avito_prod_floor_is_capped_by_calibrated_cap_mult) instead of leaving
the avito slice skipped in the false-kill coverage test.
3. Confirmed (SSH read-only, prod counts): active rows aged >60d that this PR
cannot touch regardless of cap_mult -- cian/novostroyki 9483, cian/NULL
211, yandex/NULL 523 (0 inside the jobs' actual scope: cian/vtorichka,
yandex/vtorichka). deactivate_stale_cian/_yandex are scoped to
segments=['vtorichka'] by a deliberate, documented DECISION (blanket TTL on
novostroyki risks killing live inventory cian/yandex don't fully sweep).
Widening that scope is a separate, riskier investigation and is out of
scope here -- documented the gap directly in the module docstring next to
the existing DECISION so it isn't lost.
Verification (SSH read-only against prod, 2026-08-15): recomputed the exact
per-source formula the next scheduled run will use. In-scope next-run
deactivation is currently 0 for all four sources -- the active pool has
already self-corrected to be consistent with each source's own recent
effective TTL (yesterday's yandex run used effective=54, so no active row is
older than that yet). This matches the round-2 reviewer's own conclusion: the
cap is a preventative guardrail, not a retroactive cleanup, and isn't expected
to fire on the exact day it's calibrated. It is not idle, though -- live
recompute of yandex/vtorichka's raw (uncapped) floor right now is 78.2d,
already above its 60d ceiling; the trailing 6-day counters show the identical
loop (floor=75, deactivated=0, three days straight) already recurred twice
without this cap in place. The mechanism will bind the moment the pool ages
past the ceiling, which is exactly the recurrence it exists to stop.
Tests: 106 passed (test_deactivate_stale_ttl_cap.py,
test_deactivate_stale_revisit_floor.py, test_deactivate_stale_health_gate.py,
test_deactivate_stale_listings.py, test_migrations_manifest.py). ruff clean.
scripts/check-migration-lock-timeout.py: pass (UPDATE-only migration, no
blocking DDL, no SET LOCAL needed).
Дизайн-макет владельца (15.08.2026). Собрано рядом с живым лэндингом, а не
вместо него: /mera-public открыт на meraocenka.ru, менять его посреди
согласования дизайна нельзя.
- b2c-tokens.ts — палитра макета отдельно от v2/tokens.ts. Красить теми же
токенами B2B-экран нельзя, это другой продукт; hex-литералы остаются в
одном файле.
- marketing-v3.ts — числа из макета в ОДНОМ месте, с таблицей расхождений
с нашими замерами и гейтом: заменить вычисляемыми до снятия noindex.
- _components/v3/ — шапка с селектором города, подвал, липкая CTA, герой
с карточкой проверки, три шага, «Точность», «Цена ошибки».
- layout.tsx больше не рендерит шапку/подвал: вложенный layout в Next
ДОБАВЛЯЕТСЯ к родительскому, поэтому v3 со своим хромом получал бы две
шапки. Теперь хром выбирает страница — v1 берёт SiteHeader/SiteFooter,
v3 свой.
Отступления от макета (все задокументированы в коде):
- реквизиты и документы берутся из LEGAL_ENTITY, а не из маски
«ООО «МЕРА» · ИНН 66700XXXXX» в макете;
- в подвал добавлены Политика возврата и почта поддержки, которых в макете
нет, — обязательные элементы платного контура;
- бейджи МИР/СБП/VISA не рендерятся: приём платежей не подключён;
- форма не выдаёт результат — анонимного расчёта нет, а одинаковые
«14 объявлений» на любой адрес это обман конкретного человека, а не
продающая формулировка. Ветка результата свёрстана и ждёт /coverage;
- замыленная рублёвая цифра не перенесена: в макете она лежит в разметке
текстом под filter:blur и достаётся из исходника;
- акцент #16A9BC не используется как цвет текста (2,6:1 на фоне страницы) —
для текста затемнённый #0B6B79, 5,8:1;
- города и объёмы в селекторе — из нашего реестра, а не 42 города РФ.
Секции макета, требующие отсутствующих данных и контуров (лента
прогноз→факт, игра, продажа под ключ, статьи, модалка, оплата), не
верстались — заведены задачами #2894-#2904.
Проверено: tsc --noEmit, next lint, isolation guard (31 файл),
next build — /mera-public/v3 пререндерится статикой.
Джоба openapi-codegen-check покраснела на этой ветке: она дампит app.openapi(),
регенерирует frontend/src/types/api-types.ts и падает на расхождении. Добавленный
HEAD /health попал в схему и потребовал правки сгенерированного файла.
Регенерировать типы ради маршрута, который фронт никогда не вызывает, — лишний
шум в generated-коде. HEAD-проба это инфраструктура для uptime-монитора, а не
часть контракта, по которому фронт строит типы, поэтому include_in_schema=False
здесь и по смыслу верно, а не только удобно.
Флаг ставим в обоих бэкендах симметрично: у trade-in codegen-джобы пока нет, но
расхождение схем между двумя бэкендами потом само станет источником вопросов.
Review round 2 on #2626 (local houses fallback) found two HIGH-severity bugs
verified live against prod data:
1. _extract_local_house_token took the LAST digit-like token in the raw
address, so "...Педагогическая, д 15, кв 11" resolved house=11 (apartment
number) instead of 15 -- confidently returning a stranger's building with
confidence='exact', written to geocode_cache. Fixed by stripping the
apartment/office/floor/entrance tail (кв/оф/пом/подъезд/этаж -- NOT
корп/к, which is part of the house number) before extracting the token.
Fixes the exact prod case from the review plus the corpus+apartment
combo ("д 26 к 1, кв 41" -> 26к1, not 41).
2. houses is not an EKB-only table (21% of rows with coords are outside the
metro, some as far as another city) -- "улица Маяковского, 7" in houses
resolves to Серов, not Екатеринбург, and use_local_ekb only gates the
user's query text, not the source row. Added an is_within_ekb_bbox_wide
check on every candidate row before it can become a match.
Also addressed two MEDIUM findings from the same review:
3. The "<номер> -> <номер>к1" corpus guess only checked uniqueness among
к1-labelled rows, so real multi-building addresses (Онуфриева 24: к1/к2/к3,
250-400m apart) resolved confidently to к1 anyway. Guess is now skipped
when any other corpus/slash variant of the same base number exists among
the street's candidates.
4. Houses-fallback results are no longer cached in geocode_cache -- the
source (scraped listings) is less reliable than geoportal/cadastral/
Nominatim, and the lookup is cheap/local, so caching only extended the
lifetime of a possible bad match. Side benefit: address_refined now
survives every repeat request of the same raw address, not just the
first.
Also added ORDER BY address, id to the underlying query so the coordinate
dedup picks a deterministic row (LOW finding #5).
14 new/updated tests in test_geocoder_local_houses_fallback.py cover all
five findings against real prod address/houses-row fixtures. Full geocoder
+ dadata + estimator/pdf regression suite (402 tests) green.
Ревью честного run-status нашло, что _RESULT_COUNTER_KEYS ловил не только целевой
yandex_newbuilding_sweep, но и rosreestr_dkp_import (rows_inserted, 66 из 67 прод-
прогонов = здоровый ноль догнавшего инкрементального импорта) и newbuilding_enrich
(processed — счётчик попыток, ==limit даже при частичном провале). Первое завело бы
практически непрерываемый ложный zero-стрик у здорового источника, второе маскировало
бы реальные отказы под measured-N.
Проверено по прод-БД (2026-08-15): "succeeded" пишут ТОЛЬКО yandex_newbuilding_sweep
(42 прогона/90д) и newbuilding_enrich (65/90д) — ни разу rosreestr_dkp_import; у
yandex_newbuilding_sweep succeeded численно совпадает с rows_inserted на всех 42/42
прогонах. Заменил "rows_inserted"+"processed" на "succeeded" в _RESULT_COUNTER_KEYS
(app-копия и byte-эквивалентная kit-копия) — цель (b) исходной правки сохранена, ложный
стрик у rosreestr_dkp_import снят, попутно newbuilding_enrich получает честное
измерение вместо счётчика попыток.
Также поправлены докстринги test_backfill_honest_status.py — два кейса (76%/72%
отказов -> 'done') проверяют только выбор финализатора mark_backfill_finished
(mark_done там замокан); реальный mark_done с honest-run-status переквалифицирует их
в 'failed' через _failed_ratio_too_high — это не документировалось явно.
Review of 3a1e29a7 found CAP_MULT=2 is uniform across sources with wildly
different ttl_days, so it produces a different ABSOLUTE ceiling per source:
cian/yandex (ttl=30) -> 60d, avito (ttl=10) -> 20d, domklik (ttl=14) -> 28d.
That breaks exactly where the crawl's revisit tail doesn't scale with
ttl_days: avito's measured p99 revisit gap is 42.1d (_REVISIT_TAIL) --
above its own default cap of 20d -- so a legitimately slow-but-alive avito
crawl cycle would get its floor cut below the very tail the floor exists
to protect (the false-kill scenario #2659 was filed for). cian/yandex/
domklik aren't affected: their default ceilings (60/60/28) already sit
comfortably above their own measured tails (26.6/43.0/3.1).
Fix: cap_mult is now a function parameter (same pattern as
revisit_floor_quantile/min_confirmations) with the module constant CAP_MULT
as its default, wired through product_handlers via default_params["cap_mult"]
so a schedule can override it without touching the shared default. Also
closes the ttl_days<=0 edge case flagged in the same review: before the cap,
max(ttl_days, floor) tolerated a misconfigured ttl_days<=0 as long as the
floor was positive; with the cap, min(floor, ttl_days*cap_mult<=0) would
silently defeat that protection and match nearly the whole active pool.
ttl_days<=0 now raises ValueError before any SQL, same contract as the
existing staleness_column whitelist check.
Also verified (read-only, postgres-tradein) the review's core "no-op"
claim: false. scrape_runs.counters for the 6 days since the revisit-floor
went live (08-10..08-15) show the cap DID bind on 3 of 6 runs for avito
(floor 52 vs cap 20) and 3 of 6 for yandex (floor 75 vs cap 60) -- the
reviewer's "no source hits the cap" read a single-day trough right after
a natural recovery, not the whole observation window. See PR discussion
for the full counter history and refutation detail.
Refs #2659
Ревью R2 нашёл, что вся безопасность предыдущего фикса держалась на
недоказанной поддержке act_runner'ом steps.<id>.outcome: если раннер его
не заполняет, retry-шаг молча не бежит, continue-on-error проглатывает
падение сборки, job зелёный — а деплой тянет старый :latest на прод.
- Добавлен engine-agnostic verify-шаг после каждого retry (6 мест,
deploy.yml + deploy-tradein.yml): `docker buildx imagetools inspect
<image>:<sha>` без continue-on-error. Не зависит от того, поддерживает
ли раннер outcome — проверяет реальное состояние registry напрямую.
Если ни build, ни retry реально не запушили образ — шаг падает и job
честно FAILURE независимо от семантики outcome.
- Вернул `cache-to` в retry-шаги (6 мест): без него битый buildcache-тег
никогда не перезаписывался — retry всегда собирал без cache-to, значит
cache-to не выполнялся НИКОГДА, и каждый следующий прогон снова падал
на том же cache-from. Заявленное самолечение не работало ни разу.
- Health-check в deploy.yml (main-стек) под `set -e` не мог упасть:
`curl ... && break` — curl не последняя команда &&-списка, POSIX
освобождает такие команды от errexit, цикл дохаживал до sleep (exit 0)
даже если curl ни разу не отдал 200. Приведено к паттерну
deploy-tradein.yml: явный флаг healthy + `exit 1` после цикла.
Подтверждено локальным bash-репро (mock curl, всегда failure): старая
версия — exit 0, новая — exit 1; позитивный сценарий не сломан.
docker rm -f без -v в SSH-скриптах деплоя не тронут.
Review-разбор ветки fix/tradein-uptime-honest-green:
1. [HIGH] Прод-симптом `HEAD gendsgn.ru/health -> 405` обслуживает Site
Finder (Caddyfile:60 `handle /health { reverse_proxy backend:8000 }`),
а предыдущий коммит правил только tradein-mvp/backend, чей /health наружу
не проксируется вообще. Добавлен @app.head("/health") в backend/app/main.py
рядом с существующим @app.get — эмпирически подтверждено (uv run pytest):
HEAD было 405, стало 200. tradein-mvp фикс не откачен (безвреден, годится
для будущего internal-caller), но обвязан комментарием, что реальный
прод-путь чинится не там.
2. [LOW] Response(status_code=200) без media_type отдавал HEAD без
Content-Type, тогда как GET отдаёт application/json — расходится с
заявленным в комментарии RFC 9110 §9.3.2. Добавлен media_type в обоих
бэкендах; Content-Length сознательно не подгоняем под байты GET-ответа
(payload header field, RFC разрешает опускать для HEAD) — не дублируем
сборку payload ради байт-в-байт соответствия.
Тесты: test_health_head_ok_no_body добавлен в backend/tests/test_health.py
(Site Finder) — RED-check (git stash app/main.py) воспроизводит прод-баг
1:1: assert 405 == 200. tradein-mvp/backend/tests/test_health_endpoint.py
дополнен проверкой Content-Type. uv run pytest — все зелёные.
28/1084 прод-оценок имели lat IS NULL — гарантированный ноль аналогов, клиент
не получал оценку вовсе. Дом уже был в houses (скрейпленные листинги), но не
резолвился ни geoportal/cad_buildings, ни Nominatim: разговорное/усечённое имя
улицы («Онуфриева» вместо ГАР-каноничного «Начдива Онуфриева») или отсутствующий
в вводе корпус («49» вместо реального «49к1»). Добавлен последний тир geocode()
с двумя defensive-допущениями (суффиксный матч улицы + опциональная догадка
«номер+к1») — при любой неоднозначности возвращает None, а не гадает; проверено
живыми прод-адресами (Онуфриева/Хрустальногорская резолвятся, Крестинского
корректно остаётся неоднозначным — два разных дома в houses под одним номером).
Отдельно: HTTP 403 «услуга CLEAN выключена на аккаунте» логировался как ERROR
на каждый /estimate (164 события) — это статичная конфигурация аккаунта, а не
сбой; понижено до WARNING (первый раз за процесс) + DEBUG на повторы, чтобы
ERROR продолжал значить настоящую проблему.
83% of tracker issues (7460 total) were pure noise drowning real signal:
- basic_auth 401 (3738 issues, 2019 distinct titles) — ops/glitchtip-auth-
forwarder sent EVERY 401 from bots scanning gendsgn.ru (GET /wp-admin/
install.php etc.) as an individual GlitchTip event, remote_ip baked into
message/tags inflated cardinality. Not an application error — expected
bot-scan traffic against a basic_auth-protected site.
- RetryError (2462 issues) — geocoder.py's three tenacity @retry-wrapped
Nominatim helpers (lookup/suggest/reverse) raised tenacity.RetryError on
exhaustion without reraise=True; RetryError.__str__() embeds a Future
repr() with a memory address that differs every call, so GlitchTip
grouped each exhausted retry as a distinct issue instead of one.
Fix at the source, not post-hoc issue cleanup:
- forwarder.py: before_send drops events tagged event_type in
{basic_auth_failed, basic_auth_storm}; forwarder's own capture_exception
(real script bugs) carries no such tag and passes through untouched.
- geocoder.py: reraise=True on all three @retry decorators — propagates
the real underlying exception (stable type + stacktrace) instead of the
unstable RetryError wrapper.
- sentry_scrub.stabilize_retry_error_fingerprint: belt-and-suspenders
before_send hook, composed into both app/main.py and scheduler_main.py
(geocoder runs in both processes — FastAPI request path and the
overnight geocode_missing_listings batch). Collapses any RetryError that
still slips through into one persistent issue per cause-exception type
name only — never IP/address/listing-id.
Content-ful categories (OperationalError, city-sweep, harvest_quarter,
cian/avito/yandex sweep failures, scrape_freshness_check — ~700 issues)
are untouched: filters key off event_type tag / exception type name only.
Три прод-факта, где status='done' врал о реальном исходе прогона:
- avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1}
-> 'done'. mark_backfill_finished звал mark_done, потому что produced=6 (>0);
ни _sweep_run_did_nothing (нет anchors_total/errors_count у backfill'ов), ни
_phase_totally_failed (голые "attempted"/"failed" без фазового префикса) эту
форму counters не ловили. Новый _failed_ratio_too_high внутри mark_done:
failed/attempted >= 0.5 -> 'failed', >= 0.15 -> тоже 'failed' (другая
формулировка причины в error-тексте) — 'partial' статусом не заведён: это
потребовало бы DROP+ADD CHECK constraint (051_scrape_runs_extend.sql) и
дообучения ещё 4 мест (Literal-фильтр admin API, статусы фронта, оба
IN-списка сторожей) — тот же класс проводки, что и у ban_kind (#2686/#2764),
который сознательно не стал новым статусом.
- yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при
processed=5 succeeded=0 rows_inserted=0 failed_resolve=4-5 — сторож нулевого
результата (_alert_if_consecutive_zero_results) не видел ни один результатный
ключ этого sweep'а и молчал навсегда. _RESULT_COUNTER_KEYS дополнен
rows_inserted/processed (именно в этом порядке — rows_inserted это результат,
processed это попытки; иначе "5 обработано, 0 записано" замаскировалось бы
под measured-5).
- admin-витрина показывала new_count=0 у трёх подряд cian_full_load при реально
сохранённых saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count',
ни 'lots_inserted'. _column_counts дополнен saved_inserted/rows_inserted.
Правки продублированы в scraper_kit/orchestration/runs.py (byte-эквивалент
app.services.scrape_runs, см. докстринг модуля) для параллели: единственный
текущий писатель "attempted"/"failed" (mark_backfill_finished) живёт только в
app-копии, но приоритет ключей/константы держим синхронными на будущее.
Не тронуто: сознательно пустые sweep'ы (errors_count=0, honest empty) и малые
батчи (attempted < 3) — доля отказов на них не считается диагнозом.
Tests: tests/test_honest_run_status_failed_ratio.py (41 кейс, оба модуля,
включая точные прод-числа из трёх фактов выше) + regression-прогон 609 тестов
по всем файлам, трогающим scrape_runs/orchestration.runs — 0 регрессий.
@app.get("/health") в FastAPI/Starlette не добавляет HEAD-обработчик
автоматически (в отличие от низкоуровневого Route(methods=["GET"])) —
внешний uptime-monитор (GlitchTip PING-тип шлёт HEAD) получал 405 и не
мог отличить "жив" от "мёртв" по статусу. Добавлен явный
@app.head("/health") — 200 без тела (RFC 9110 §9.3.2), GET не тронут.
Тест test_health_endpoint.py фиксирует оба метода; RED до фикса
(HEAD → 405), GREEN после (проверено git stash + повторный прогон).
Revisit-floor (#2659) raises effective TTL via max(ttl_days, floor) with no
upper bound -- a positive feedback loop confirmed on prod: slow crawl raises
the floor, a high floor keeps stale listings marked active longer than a
fresh sweep needs to return, the "active" pool bloats with rot, and the next
floor measurement on that bloated pool comes out even higher. Yandex counters
sat at ttl_days_effective=75/75/75/39/52/54 for six runs straight with
deactivated=0; 23,687/44,744 "active" avito listings hadn't been confirmed in
>7 days, cian 10,572/19,514 and yandex 7,178/15,790 were >30 days stale, the
oldest "active" row hadn't been seen in 86 days.
CAP_MULT=2 caps the floor's upward push without disabling it -- the floor
still protects against premature deactivation during genuinely slow (but
alive) crawl cycles, it just can no longer grow unbounded. Beyond 2x, a
persistently low crawl rate is better handled by the existing health gate
(min_confirmations), which disables deactivation outright instead of
stretching TTL forever.
When the cap binds, counters gain ttl_floor_capped=1 + ttl_days_floor_raw
(the uncapped value) so it's visible in the run-history dashboard, not just
logs -- counters are stored as-is in scrape_runs.counters.
Single fix point: all four sources (avito/yandex/cian/domklik) route through
this one deactivate_stale_listings() via the product_handlers wildcard
"deactivate_stale_*" handler, so no other task file needed the change.
Зелёная галка прогона не отличима от пропущенного деплоя: если build падает
из-за битого blob в удалённом buildcache, шаг deploy молча пропускается
(if-условие даёт result=skipped), а прогон в целом не подсвечен как FAILED.
- deploy-status: новая job в конце deploy.yml и deploy-tradein.yml, всегда
бежит (if: always() && !cancelled()) и падает явно, если deploy.result !=
success — неважно, пропущен он (upstream build/test упал) или упал сам.
- cache-from нефатален: каждый build-push-action-шаг получил id + continue-
on-error, и ретрай без cache-from/cache-to при steps.build.outcome ==
'failure'. Битый remote-кеш больше не роняет саму сборку; следующий
успешный прогон с кешем перезаписывает buildcache-тег целиком (mode=max)
и самолечит порчу. Реальные ошибки сборки (не кеш) по-прежнему валят job
на ретрае — deploy-status их тоже поймает.
Гейт против публикации services-портов на VPS (та же задача, проблема 1)
уже покрыт scripts/check-workflow-ports.py + шагом в ci.yml (#2757/#2759,
слит ранее) — сканирует все .forgejo/workflows/*.yml, включая эти два файла;
новых правок не потребовалось.
docker rm -f БЕЗ -v в SSH-скриптах деплоя не тронут — эти вызовы намеренно
без -v (боевые тома), правка их не касается.
Оба скрипта закоммичены как 100644, хотя соседние по каталогу backup.sh и
uptime-healthcheck.sh — 100755. На прод-VM файлы лежат с +x, поэтому
`git status` в /opt/gendesign постоянно показывает их как modified:
M ops/docker-prune.sh
M ops/restore.sh (old mode 100644 / new mode 100755, контент идентичен)
Функционально это ничего не ломает: cron зовёт docker-prune.sh через
`bash <путь>`, restore.sh запускают руками так же. Проблема в другом —
постоянный M в прод-репозитории обесценивает единственный дешёвый сигнал,
по которому видно ручную правку файла на проде. Шум надо убирать, а не
привыкать к нему.
Приводим режим к тому, что реально на диске и что уже стоит у соседей.
Скрипт уборки docker-мусора (#2887) исполняется на прод-VM по cron из
/opt/gendesign/ops/. Файлы туда попадают единственным путём — шагом
`git reset --hard origin/main` внутри deploy.yml.
Но paths-фильтр deploy.yml перечисляет подпути ops/ поимённо, а не ops/**.
Поэтому мерж #2887 деплой НЕ запустил: скрипт остался в main, на VM его не
было, а установленный cron указывал в пустоту. Правки скрипта и дальше
доезжали бы только случайно — со следующим чужим коммитом в backend/.
Ровно этот же баг уже ловили на ops/db-bootstrap/** — там рядом стоит
комментарий с той же формулировкой. Добавляю ops/docker-prune.sh по образцу
и фиксирую грабли в rules/deploy.md, чтобы следующий исполняемый файл в ops/
не наступил на них третий раз.
Диск был занят на 76% (110 из 145 ГБ). Разбор: 201 том-сирота на 12.6 ГБ —
125 анонимных (каталоги данных PostgreSQL от тестовых прогонов CI) и 76
окружений задач Forgejo Actions. Прод-данных среди них нет ни одного.
Корневая причина: ci.yml и ci-tradein.yml поднимают свой postgres и снимают
его через `docker rm -f` БЕЗ `-v`. Контейнер уходит, анонимный том с данными
остаётся сиротой — по одному на каждый прогон CI.
- ci.yml / ci-tradein.yml: `docker rm -f "$CI_PG"` → `docker rm -fv` (4 места).
В deploy-*.yml тот же вызов применяется к БОЕВЫМ контейнерам — туда -v
добавлять нельзя, снесло бы тома с данными прода. Не тронуто.
- ops/docker-prune.sh — страховка на то, что runner не убрал за собой.
Удаляет: остановленные контейнеры старше 24ч, висячие образы старше 7 суток
и тома-сироты ТОЛЬКО двух известных форм (64-символьный hex и
FORGEJO-ACTIONS-TASK-*). Именованные тома не трогаются никогда — голый
`docker volume prune` такой разницы не делает, поэтому здесь не используется.
Порядок важен: контейнеры → образы → тома, иначе освободившиеся после
контейнеров тома останутся до следующего запуска (на этом я и споткнулся
при ручной чистке — после prune осталось ещё 78 сирот).
Проверено на проде: DRY_RUN, затем боевой прогон, затем повторный — no-op.
Тома 220 → 15, все используются, освобождать нечего. Диск 76% → 67%.
Cron поставлен: вс 04:00 UTC (свободный слот рядом с бэкапами).
Оба модуля были созданы параллельно в разных ветках под один и тот же файл:
здесь — путь к политике ПДн для ссылки в чекбоксе согласия (блок 2),
в #2884 — короткая оговорка под диапазоном цены (блок 4.1). Обе константы
живут в одном модуле-без-импортов, шапка объединена.
РКН/владелец: рядом с чекбоксом согласия должна быть ссылка на сам документ
политики обработки ПДн, а не упоминание закона. Чекбокс в LeadForm.tsx
(v2, живой /trade-in/v2) теперь линкует "Политикой обработки персональных
данных" на /mera-public/privacy (target=_blank, чтобы не терять заполненную
форму). Путь вынесен в новый src/lib/legal-copy.ts (модуль без импортов) —
content.ts ре-экспортирует оттуда, чтобы B2B-виджет не тянул B2C-лэндинг-модуль
целиком.
_CONSENT_TEXT_SNAPSHOT/_CONSENT_POLICY_VERSION в lead.py обновлены под новый
плоский текст и дату утверждения политики (PRIVACY_APPROVAL: 2026-08-13).
test_consent_text_frontend_sync.py: экстрактор теперь снимает JSX-теги/{" "}
спейсеры перед сравнением (иначе сломался бы на разметке ссылки) + новый тест
держит _CONSENT_POLICY_VERSION в синхроне с PRIVACY_APPROVAL из content.ts,
чтобы версия не расходилась молча с редакцией документа.
Легаси-дубль в HeroTransparency.tsx (недостижим с живого роута) — текст
приведён в соответствие без ссылки: компонент не смонтирован нигде, и нет
теста, который держал бы там ссылку в актуальном состоянии.
Юр-документ владельца от 14.08.2026, блок 3: верхний дескриптор и заголовок
вкладки должны уводить сервис от слова, отсылающего к отчёту оценщика по
135-ФЗ, и явно называть основание расчёта — рыночные данные.
- Шапка лэндинга: «Оценка вторичного жилья · <регион>» →
«Оценка вторичного жилья по рыночным данным · <регион>». Слово «МЕРА»
остаётся вордмарком слева (у него свой letter-spacing), строка складывается
из двух узлов; регион по-прежнему из REGION_NAME, не литералом.
- Заголовок вкладки: «МЕРА — оценка квартиры на вторичном рынке» →
«Мера · расчёт стоимости квартиры по рыночным данным».
H1 первого экрана («Сколько на самом деле стоит ваша квартира») НЕ меняется —
решение владельца: он не заявляет ничего про официальную оценку, а продающий
заголовок терять незачем. Заголовки трёх юридических подстраниц живут по
своему шаблону «<Документ> — МЕРА» и не затронуты.
Проверено: tsc --noEmit, next lint, mera-public isolation guard (18 файлов).
Блок 4.1 юр-требований владельца (14.08.2026): под диапазоном цены на
экране результата обязана быть эта строка. Вынесена в новый модуль
lib/legal-copy.ts (SHORT_ESTIMATE_DISCLAIMER, без импортов) — используется
и в v2/ResultPanel.tsx (боевой экран /v2), и в HeroSummary.tsx (legacy-контур
/trade-in/ui-preview/estimate), первым предложением в уже существующем
абзаце-дисклеймере про рыночный разброс. В ResultPanel.tsx подрезаны
lineHeight/marginTop/padding соседнего блока, чтобы новая строка не сжимала
плитки "ИСТОЧНИКИ ДАННЫХ" на фиксированной высоте артборда.
Running @bottom-center margin-box печатал только мета/wordmark — юр-требование
(индикативный расчёт, не отчёт об оценке по 135-ФЗ) отсутствовало на всех 4
страницах. Бюджет высоты подвала = margin-bottom (19mm≈53.9pt) уже был
заполнен почти впритык (~51pt) после 42a50cf8 (ровно 4 страницы без пустых).
Сжат существующий HUD-хром внутри _page_footer (margin-top 6→4pt, padding-top
8→6pt, line-height мета/wordmark 1.35→1.15, разделитель margin 6pt 0→3pt 0,
экономия ~13pt) + добавлен текст дисклеймера отдельным блоком (5pt/line-height
1.15, ~3 строки ≈17pt). Экономии внутри подвала не хватило без деградации до
нечитаемого — минимально поднят @page margin-bottom 19mm→21mm (+2mm).
Реальный WeasyPrint-рендер (native Pango/cairo) недоступен на Windows-деве —
пагинация (риск отката к 5-й пустой странице из-за margin-bottom на всех 4
страницах) не подтверждена локально, арифметика в docstring _page_footer.
Лоадер ЕЭСК писал степень загрузки из колонки E как
`load_index = COALESCE(load_index, CAST(:load_pct AS text))`.
load_index — категориальная: 'open'|'limited'|'closed'|NULL
(data/sql/180_connection_capacity.sql:35), её заполняет
rosseti_wfs_loader._map_load_index.
Число строкой в этой колонке ломает обе стороны: фронтовый classifyLoadIndex
отбрасывает всё вне перечисления в null («неизвестно»), а
power_summary.by_load_index — словарь по значению, то есть получил бы бакет
с именем вида "41.0" рядом с open/limited/closed.
Сегодня не стреляло только потому, что load_index заполнен у всех строк
(open 2741 / limited 346 / closed 329, NULL 0 — замер верификации 13.08,
подтверждён вторым прогоном скептика), и COALESCE не проваливался. Первая же
строка с пустым индексом положила бы туда число.
Колонку E больше не читаем: места под процент в power_supply_centers нет —
load_index категориальный, current_load_mva в мегавольт-амперах.
_pct_share_to_percent оставлен с тестами, но в докстроке теперь прямо
написано, что продакшен-вызывающих у него НЕТ и при каких условиях он снова
понадобится — чтобы «код есть, эффекта нет» не выглядел работающим.
Старый тест фиксировал ровно отменяемое поведение (`first["load_pct"] == 41.0`)
— заменён на проверку, что ни SQL, ни параметры загрузку не несут. Проверять
пришлось исполняемый текст, а не прозу: слово load_index осталось в поясняющем
комментарии, и наивная проверка на подстроку падала на своём же объяснении.
Тесты двусторонние: против лоадера из main падает ровно новый.
Хунк форматирования — не мой: pre-commit ruff v0.7.4 против 0.15.12 (#2864).
Refs #2464
Юридический блокер публичного B2C-запуска (G6 в mera-b2c-paid-flow-decision.md:
`LEGAL_ENTITY == null` → нет реквизитов, нет продажи) и входное требование
модерации эквайера: оферты не было вообще, а privacy-страница в собственной
шапке писала, что она НЕ утверждённая политика по ст. 18.1 152-ФЗ.
Что сделано:
- content.ts: `LEGAL_ENTITY` заполнен (ООО «ПРОЕКТ ФЛЭТ», ИНН/КПП/ОГРН, адреса,
директор, банковские реквизиты), добавлены `SUPPORT_EMAIL`,
`SERVICE_PRICE_RUB`, пути и короткие публичные URL документов. Единый
источник — подвал, оферта, возврат и ПДн рендерят эти поля, а не повторяют
строки. ОГРН сверен с открытыми данными ЕГРЮЛ (в исходном сообщении владельца
был с опечаткой …028028, верный …028281).
- Новые страницы /mera-public/oferta и /mera-public/refund — редакции владельца
от 13.08.2026 дословно, плейсхолдер `[адрес электронной почты]` заменён на
support@meraocenka.ru.
- privacy/page.tsx переписана на утверждённую редакцию с оператором и
реквизитами приказа. Сохранены оба инварианта честности: раздел про страницу
ввода адреса условен по `PUBLIC_ESTIMATE_ENABLED` (п. 5.4 — пока публичный
расчёт выключен, адрес не покидает браузер), срок хранения оплаченного отчёта
рендерится из `PAID_REPORT_RETENTION_MONTHS`, а не числом в тексте
(test_paid_retention_text_consistency.py).
- Caddyfile: короткие адреса /oferta, /refund, /privacy → rewrite на поддерево
лэндинга. Именно они напечатаны внутри документов и уйдут в заявку эквайеру.
Пути перечислены поимённо — allowlist-by-default периметра не ослаблен.
- smoke-mera-perimeter.sh: три новые проверки на короткие адреса.
Публикация оферты НЕ включает приём оплаты: платёжного контура в коде нет,
`PUBLIC_ESTIMATE_ENABLED` по-прежнему false. Оферта публикуется раньше кнопки
намеренно — без неё эквайер не примет заявку.
Проверено локально: tsc, next lint, vitest (29), isolation guard, next build
(три страницы пререндерены), pytest test_paid_retention_text_consistency (3),
caddy validate + adapt (rewrite на месте), рендер всех трёх страниц через
next start — реквизиты, почта и цена на месте.
price_index нормирован на медиану Екатеринбурга (99a_quarter_price_index.sql),
поэтому фолбэк `avg_analog_index = ... else 1.0` подставлял в знаменатель
gap-коррекции не «нейтраль», а уровень ЕКБ. Для цели вне ЕКБ (индексы области
0.28–0.82) это превращало поправку в безусловную скидку: factor = target_qi,
после клампа до −40%, с подписью «Учтена локация квартала» — то есть догадка
выдавалась пользователю за методику.
Нет данных → нет поправки. Ровно тот же factor=1.0 получается из avg := target_qi,
и это лучшая оценка неизвестного avg на живых данных: медиана |ошибки| 0.116
против 0.161 у 1.0, p90 0.337 против 0.517 (400 лотов, 2026-08-12).
Проверка направления на сделках Росреестра (12 мес, медианы ₽/м² по городам):
без поправки ошибка +0…+14%, с текущей поправкой −32…−40%. Правка поднимает
цену и одновременно уводит её к правде, а не просто вверх.
MV и FDW не трогаются намеренно: строки basis='district'/'city_fallback' имеют
n_deals 3–4, а эстиматор требует n_deals >= 10 — второй 1.0 (city_fallback в
99a) до него структурно не доходит (прод: 0 из 1894 строк видимы).
Тесты: два прежних кейса задавали уровень аналогов отсутствием кадастра,
то есть опирались на сам дефект — переведены на явную карту analog_indexes.
Refs #2583
Параллельные планы Постгреса размещают DSM-сегменты в /dev/shm
(dynamic_shared_memory_type=posix). Контейнеру БД никто не задавал shm_size,
поэтому там держалось умолчание Docker — 64 МБ, и запросы Объектива падали
с psycopg.errors.DiskFull «could not resize shared memory segment».
Текст ошибки называет НЕ тот ресурс: на разделе 28 ГБ свободно, кончался
именно /dev/shm.
Замеры на проде 2026-08-10 (не рекомендация из интернета):
постоянный расход ~9.8 МиБ (DSA кумулятивной статистики pgstat)
один параллельный ~15.4 МиБ (3 одновременных → 55.9 МиБ из 64)
→ 4-й одновременный запрос не влезает; ровно это и наблюдалось
(6 отказов за 1.2 с из двух backend-процессов)
Нижняя граница: 128 МиБ покрывают лишь ~7-8 одновременных, а потолок
celery (--concurrency=8) плюс request-path даёт ~12 → минимум 256 МиБ.
Верхняя: /dev/shm это tmpfs, страницы выделяются по факту, поэтому
значение — потолок, а не резерв; на хосте 11 ГиБ RAM (свободно ~6),
2 ГиБ я бы не переходил. 1 ГиБ = ~65 таких запросов, ~5x запаса.
Локальная проверка A/B (postgres:16, GUC как на проде, различается только
--shm-size): 6 одновременных параллельных запросов
64m → 4 отказа, пик 57.3 МиБ (упёрлись в лимит)
1g → 0 отказов, пик 113.3 МиБ (реальный спрос вдвое выше 64 МиБ)
Refs #2812
Во втором коммите PR отдельный `git fetch origin main` был снят (из
job-контейнера git.gendsgn.ru недостижим, run 6977), эталон даёт сам checkout с
fetch-depth: 0. Комментарий про «тянут main отдельным шагом» остался и читался
бы дальше как факт.
Конфликт modify/delete по tradein-mvp/backend/data/sql/_manifest_applied.txt
разрешён удалением — удаление и есть предмет PR. За трое суток в main дописали
три имени (240/250/251); дописывать их некуда: файла больше нет, а гейт
tests/test_migration_numbering.py берёт эталон применённого из origin/main.
Проверено, что удаление ничего не оставляет без потребителя: манифест не *.sql,
цикл миграций в deploy-tradein.yml и bootstrap схемы в ci-tradein.yml берут
glob '*.sql', новый scripts/check-migration-lock-timeout.py — тоже.
Замер дрейфа на 27e199e3: 216 имён в манифесте против 232 файлов, отставание
16 (было 15 на 07.08). Старый гейт на этом дереве: 4 passed.
# Conflicts:
# tradein-mvp/backend/data/sql/_manifest_applied.txt
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей
(PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile,
roles.yaml, auth_session.py не тронуты.
- sentry_scrub.py: новая scrub_payment_request_body — вырезает
event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64
кладёт полное тело запроса в request.data, send_default_pii=False это НЕ
гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS:
customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey.
- main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации
sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала,
before_send и before_send_transaction. Мотивирующий инцидент: на соседнем
продукте вчера закрыли только error-канал, transaction остался без
обработчика вообще.
- ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter
(3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное
отключение — backstop против шторма запросов остаётся, подпись проверяется
уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией).
- request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware
внешний относительно rbac_guard и читает сырой X-Authenticated-User —
спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin).
- smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout
сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401
(gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра.
Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор
(не substring — комментарии в этих же файлах сами упоминают
before_send_transaction) на проводку обоих каналов во всех точках
инициализации, 400 запросов notify без единого 429 + контроль что общий
лимитер по-прежнему активен на других путях, notify вне user_events даже со
спуфнутым X-Authenticated-User: admin.
Отдельный шаг `git fetch origin main` в backend-тестовых job'ах ронял прогон:
из job-контейнера git.gendsgn.ru:443 недостижим (run 6977, connection refused
за 5 мс), сеть есть только у самого checkout. Шаг и не был нужен — в логе того
же прогона видно, что при fetch-depth: 0 checkout идёт refspec'ом
`+refs/heads/*:refs/remotes/origin/*`, то есть origin/main появляется сам.
Refs #2683
_manifest_applied.txt по построению не мог покраснеть. Тест считал «новым»
любой файл, которого нет в списке, а новые файлы от списка освобождены
(докстринг test_manifest_covers_all_but_new_files: «НЕ требует, чтобы новый
файл уже был в manifest»). Забытое имя и новая миграция PR для гейта — одно и
то же, поэтому дрейф был не пропуском проверки, а её штатным исключением.
Замер на main 2026-08-07: 15 имён не дописано, все четыре теста зелёные —
через сутки после того, как #2692 догнал список руками.
Список при этом был лишь копией того, что git и так знает: deploy-tradein.yml
применяет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP, то есть «файл
доехал до main» и есть «имя закреплено на проде». Ведём эталон в git — и
дрейфовать становится нечему.
Кросс-ветковая дыра закрыта тем же ходом: номер нового файла сверяется с
ПОЛНЫМ origin/main, а не с рабочим деревом, поэтому коллизия с миграцией,
смерженной после ветвления, находится. Проверено на живом PR #2754
(234_trade_in_estimates_retain_until против 234_scrape_runs_ban_kind_unknown
из main): старый гейт зелёный, новый красный.
Удаление/переименование применённой миграции сверяется с ТОЧКОЙ ВЕТВЛЕНИЯ, а
не с origin/main: иначе ветка недельной давности краснела бы за чужие
миграции. Проверено — ветка от 2026-07-30 при +43 миграциях в main зелёная.
CI: checkout переведён на fetch-depth 0 + отдельный fetch main. Этот Forgejo
не публикует refs/pull/N/merge (1620 */head, ноль */merge), а на depth=1 нет
ни origin/main, ни общего предка — без этого гейту не с чем сверять, и он
намеренно красный, а не тихо пропущенный.
Контракт сведён к одной формулировке — докстринг test_migration_numbering.py;
шапка манифеста, правило 3, хвост манифеста и рецепт из .claude/rules
удалены или заменены ссылкой. Заодно исправлен сам рецепт: `git ls-tree` без
`-r` печатает каталог, а не файлы.
Refs #2683
- deploy.yml: добавлен auth/** в path-триггеры. app.core.auth кэширует
roles.yaml на весь lifetime процесса (@lru_cache), а файл монтируется
ТОЛЬКО в backend (не в worker, у него лишь ./data и ./reports) — без
триггера правка ролей вступала бы в силу в случайный момент.
- ci-tradein.yml: добавлен блокирующий шаг `ruff check` — гейт для tradein
backend его не гонял вообще. Заодно почищены 3 срабатывания RUF059
(unused unpacked vars) в test_estimator_pure_units.py:381 по established
convention (`_` уже используется в соседнем тесте того же файла).
- Удалён мёртвый .github/workflows/ci.yml (Forgejo его не исполняет; GitHub
mirror отстал на 500+ коммитов). Закомментированный postgres-services блок
НЕ перенесён — обоснование, ради которого его хотели оставить как заготовку
("тестов против живой БД нет"), уже неверно: tests/sql/ реально гоняются
против Postgres в ci.yml (#2745), и там намеренно НЕ используется `services:`
— раннер поднимает job и service-контейнеры в сети хоста, где порт 5432
занят прод-базой (см. ci.yml:91-100).
- README.md: убрана ложная привязка mypy к живому CI-гейту (только ruff +
pytest + coverage gate ≥65%); убран stale-блок про .github/workflows/ —
каталог после удаления ci.yml пуст.
2026-08-06 21:34:36 +03:00
545 changed files with 47380 additions and 3277 deletions
Эмпирика 2026-06-27: агент-аудитор на 186k tok / 33 calls упал на StructuredOutput; 5 мелких параллельных прошли.
- Поверхность больше бюджета → **дели на N узких сабагентов** (parallel при непересекающихся файлах, sequential при зависимостях). НЕ один большой.
- Промпт сабагенту: конкретный deliverable + формат ответа + границы («что НЕ делать»). Расплывчатый scope = дубли и мусор.
- **Бюджет живёт В ПРОМПТЕ, а не в голове оркестратора.** Знать лимит недостаточно — агент его не видит. Пиши в промпт явно: потолок вызовов (~20-25) и времени, список «строго запрещено» (типично: не читать исходники приложения, не ходить в git-историю, не диффать смежное), правило деградации «бюджет кончается → отдай что есть, допиши в notes что не успел».
Эмпирика 2026-08-24: два разведчика без потолка ушли на 157 и 178 ходов вместо инвентаризации — один вместо списка веток диффал SQL-миграции и разбирал Caddyfile.
- **`schema:` требует потолка РАЗМЕРА ответа, отдельно от токенов.** Payload `StructuredOutput` >~10k символов не парсится (`InputValidationError`) → повтор → вся работа агента теряется. В промпт: максимум N items, лимит символов на поле, весь ответ ≤~6000 символов, и прямым текстом «неполный ответ несравнимо лучше потерянного». Схему проектируй под краткость: длинные `detail`-поля провоцируют ровно этот отказ.
- Windows: очень длинный промпт субагенту может упасть на лимите командной строки (~8191 символ) — ещё один довод за компактность.
## Эскалация oversized-задачи (worker)
@ -28,6 +31,13 @@ Issue/задача выглядит больше одного захода (эв
- interactive: вернуть main-сессии план сплита вместо результата
## Целость результата workflow
- **Завершившийся прогон ≠ успешный.** Читай `<failures>` в уведомлении и `journal.jsonl` (по строке `result` на агента). Упавшие агенты возвращают `null`, `parallel()` их молча проглатывает, а стадия синтеза всё равно выдаёт уверенный текст с числами. Прежде чем показывать такой вердикт пользователю — проверь его несущие числа сам.
- **Восстановление:**`TaskStop` → `Workflow({scriptPath, resumeFromRunId})`. Готовые агенты реплеятся из кэша бесплатно, перезапускаются только упавшие. Правка промпта перезапускает ЭТОТ агент и все последующие (правило префикса) — правь точечно, не переписывай скрипт целиком.
- **Диагностика зависшего агента:** возраст последней записи в `agent-*.jsonl` + тип последнего события. `assistant/tool_use` без ответа при неподвижном журнале = завис. Несколько агентов замолчали одновременно = обрыв соединения, обычно лечится сам повтором — не спеши убивать.
- **Windows: скрипт workflow писать только в LF.** Перезапись через python даёт CRLF → запуск отбивается `script contains control characters`. `io.open(..., 'w', newline='\n')`.
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `ops/db-bootstrap/**`, `ops/*.sh`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack)
- `ops/*.sh` (#2203) — любой скрипт непосредственно в `ops/` уезжает на VM автоматически, дополнять `paths:` вручную для нового `ops/<name>.sh` не нужно. ⚠️ Одиночная звёздочка не пересекает `/` — **новый подкаталог** внутри `ops/` (по образцу `ops/db-bootstrap/`, `ops/glitchtip-auth-forwarder/`) под этот глоб не попадает и требует своей отдельной строки в `paths:`, иначе не доедет до `/opt/gendesign` и будет молча исполняться в старой версии
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
echo "Подлинность хоста: сверяется по INFRA_DEPLOY_SSH_FINGERPRINT."
else
echo '::warning title=SSH без проверки подлинности хоста::INFRA_DEPLOY_SSH_FINGERPRINT не задан — ключ остающегося хоста НЕ проверяется (#3029). Фолбэка на DEPLOY_SSH_FINGERPRINT здесь нет и быть не должно:это другая машина. По каналу едет INFRA_DEPLOY_SSH_KEY и выполняется git reset на /opt/gendesign. Как снять отпечаток — см. шапку этого файла.'
echo "ОШИБКА: конфиг Alertmanager не проходит проверку — стек не поднимаем."
exit 1
fi
echo "Алерты: канал задан, конфиг проверен, Alertmanager поднимается."
else
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос#3078."
fi
# ── read-only роль для датасорса GlitchTip ─────────────────────
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
# обязан быть безопасен даже при полном доступе к дашбордам.
echo "::warning title=Метрики БД не собираются::не заполнены:$missing_dsn. Хостовые метрики и логи поедут, метрик Postgres не будет. Проверь, что scripts/setup-metrics-exporter-dsn.sh нашёл DATABASE_URL/TRADEIN_DATABASE_URL."
# только если DSN есть. Без этого гарда экспортер поднялся бы с
# ПУСТЫМ DATA_SOURCE_NAME (в compose теперь `:-`, а не `:?`) и
# молча не отдавал бы метрик — ровно тот тихий отказ, ради которого
# весь стек и заводится.
#
# NB: INFRA_EXPORTER_DSN сейчас не собирает никто —
# scripts/setup-metrics-exporter-dsn.sh знает только про
# GENDESIGN_/TRADEIN_ и работает на продуктовом хосте. Пока это так,
# ветка ниже всегда даёт предупреждение, и это честно: метрик
# инфраструктурной БД действительно нет.
EXPORTER_PROFILE=""
if [ -n "${INFRA_EXPORTER_DSN:-}" ]; then
EXPORTER_PROFILE="infra"
else
echo "::warning title=Метрики инфраструктурной БД не собираются::INFRA_EXPORTER_DSN не задан. Хостовые метрики и логи поедут, метрик Postgres инфры не будет."
echo "Подлинность хоста: сверяется по ${FINGERPRINT_SOURCE}."
else
echo "::warning title=SSH без проверки подлинности хоста::${FINGERPRINT_SOURCE} не задан — ключ хоста НЕ проверяется (#3029). По каналу едет ssh-ключ и разворачивается стек CouchDB/Obsidian. После разъезда хостов (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку этого файла."
echo "Подлинность хоста: сверяется по DEPLOY_KNOWN_HOSTS."
else
SSH_HOST_OPTS=(-o StrictHostKeyChecking=no)
echo "::warning title=SSH без проверки подлинности хоста::DEPLOY_KNOWN_HOSTS не задан — ключ прод-хоста НЕ проверяется (#3029). После переезда на Selectel (#3057) этот SSH идёт через интернет: задайте секрет через ssh-keyscan -p <порт> <хост>."
# #3029: раньше stderr уходил в /dev/null, и «Host key verification
# failed» был неотличим от недоступного хоста — неверный known_hosts
# молча читался как штатный фолбэк на полную пересборку. Теперь эта
# причина называется отдельно. Fail-safe шага не меняется: RAW_SHA всё
# равно пуст, ветка build-all включается ровно как прежде.
if grep -qiE 'host key verification failed|remote host identification has changed|no matching host key' "$SSH_ERR_FILE"; then
echo '::warning title=Ключ хоста не сошёлся с DEPLOY_KNOWN_HOSTS::Проверка подлинности хоста НЕ прошла (#3029) — это не «хост недоступен», а расхождение known_hosts: сменился ключ хоста либо переехал адрес (#3057). Обновите секрет DEPLOY_KNOWN_HOSTS через ssh-keyscan -p <порт> <хост>. Шаг fail-safe:сейчас включится полная пересборка.'
echo "Причина пустого RAW_SHA: проверка ключа хоста, а не недоступность."
sed 's/^/ ssh:/' "$SSH_ERR_FILE"
elif [ -s "$SSH_ERR_FILE" ]; then
echo "ssh stderr (не про ключ хоста — хост недоступен либо иная ошибка):"
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
else
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029):при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT и секреты Trade-In вместе с DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет. Как снять отпечаток — см. шапку этого файла.'
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
else
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029):при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет — MITM забирает их разом, а деплой остаётся зелёным. Как снять отпечаток — см. шапку deploy.yml.'
if [ -z "$pulled_worker_image" ] || [ -z "$running_worker_image" ]; then
echo "WARNING (#3029): не удалось прочитать worker image id (pulled='$pulled_worker_image' running='$running_worker_image') — детект не отработал, fail-safe = пересоздаём worker как обычно."
WORKER_SERVICES="$WORKER_SERVICES worker"
elif [ "$pulled_worker_image" = "$running_worker_image" ]; then
echo "→ образ worker'а не изменился ($pulled_worker_image) — пересоздание и так no-op, worker в recreate"
WORKER_SERVICES="$WORKER_SERVICES worker"
else
# Оба трекера прогонов — строки в БД (lifecycle.py:108 kn_scrape_runs,
# lifecycle.py:267 objective_scrape_runs), литералы статуса сверены с
# кодом: 'running' в обеих таблицах. Анти-зомби: считаем только
# прогоны свежее WORKER_GUARD_MAX_SKIP_H часов (started_at /
# heartbeat_at) — иначе зависший навечно 'running' блокировал бы
# recreate worker'а бесконечно.
# NB: `assignment="$(...)" && next=...` (не отдельная строка) — под
# `set -euo pipefail` (шапка скрипта) присвоение, упавшее КАК
# ПОСЛЕДНЯЯ команда своего стейтмента, роняет весь деплой. Внутри
# AND-списка (не последним звеном) — нет, ошибка молча даёт пустой
# $kn_count/$obj_count, что и проверяем ниже. Тот же приём —
"SELECT COUNT(*) FROM kn_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval);"2>/dev/null)" \
"SELECT COUNT(*) FROM objective_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval);"2>/dev/null)" \
if ! printf '%s' "$kn_count" | grep -qE '^[0-9]+$' \
|| ! printf '%s' "$obj_count" | grep -qE '^[0-9]+$'; then
echo "WARNING (#3029): не удалось прочитать running-прогоны (psql молчит/пусто/не число) — детект не отработал, fail-safe = пересоздаём worker как обычно."
WORKER_SERVICES="$WORKER_SERVICES worker"
else
total_running=$((kn_count + obj_count))
if [ "$total_running" -gt 0 ]; then
echo "!!! WORKER RECREATE SKIPPED (#3029) — ${total_running} running runs, worker остаётся на образе ${running_worker_image} !!!"
"SELECT 'kn run_id=' || run_id || ' started_at=' || started_at FROM kn_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval) ORDER BY started_at ASC;"2>/dev/null || true
"SELECT 'objective run_id=' || run_id || ' started_at=' || started_at FROM objective_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval) ORDER BY started_at ASC;"2>/dev/null || true
"SELECT COUNT(*) FROM _schema_migrations WHERE applied_at > CAST('${DEPLOY_MIGRATIONS_START_TS}' AS timestamptz);"2>/dev/null | tr -d '[:space:]') \
|| migrations_since=""
if printf '%s' "$migrations_since" | grep -qE '^[0-9]+$' && [ "$migrations_since" -gt 0 ]; then
echo "WARNING (#3029): WORKER НАСТАРОМ КОДЕ + НОВАЯ СХЕМА — в этом деплое применились ${migrations_since} data/sql/** миграций, а worker остался на старом образе. Нужен ручной recreate после прогона: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps worker"
fi
fi
else
echo "→ активных прогонов (kn/objective) нет — worker пересоздаётся вместе с backend/beat"
WORKER_SERVICES="$WORKER_SERVICES worker"
fi
fi
fi
fi
docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps backend worker beat
--force-recreate --no-deps $WORKER_SERVICES
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile
# И в особенности новые volume mounts из docker-compose.prod.yml
@ -467,8 +1017,195 @@ jobs:
docker image prune -af || true
docker builder prune -af || true
# Health check
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (см. #2214,
# уже сделано так в deploy-tradein.yml; ревью #2841 R2 issue #3).
# `curl ... && break` под set -e НЕ мог провалить скрипт: curl — не
# последняя команда &&-списка, а POSIX прямо освобождает от errexit
# все команды AND/OR-списка кроме последней. После 30 неуспешных
# попыток цикл завершался кодом последнего sleep (0) — скрипт тихо
# продолжался, деплой уходил success с мёртвым бэкендом.
healthy=""
for i in $(seq 1 30); do
curl -fsS http://localhost:8000/health && break
if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда build падает (например, битый blob в
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
# skipped-job НЕ красит прогон явным «FAILED» так, чтобы это было видно на
# первый взгляд — итог выглядит зелёным/нейтральным, хотя прод не обновился.
# Эта job бежит ВСЕГДА (`if: always()`, кроме отмены прогона) и сама падает,
# если deploy не завершился success — неважно, пропущен он (build упал) или
# упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
# ── Быстрый путь: правка ТОЛЬКО конфига прокси (#2916) ────────────────────
#
# ЗАЧЕМ. `Caddyfile` лежит в фильтре `infra`, поэтому правка одной строки
# allowlist'а ради meraocenka.ru запускала полный деплой Site Finder:
# пересборку трёх образов, `git reset --hard` на боевой VM, применение ВСЕХ
# pending `data/sql/*.sql` в боевой БД gendsgn и `--force-recreate` бэкенда,
# воркера, beat и caddy. То есть радиус поражения правки, относящейся к
# чужому домену, — gendsgn.ru целиком, включая миграции продукта, который
# никто в этот момент катить не собирался.
#
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
# страница = новая строка allowlist'а.
#
# ПОЧЕМУ `reload`, АНЕ `up -d --force-recreate caddy`. Полный деплой
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
# принять битый конфиг и оставил бы работать старый — на общем деплое это
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
# код → job краснеет, а домены продолжают обслуживаться старым конфигом.
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
# и роняет ВСЕ домены сразу.
#
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
# вторая, уже против боевого файла после `git reset`.
deploy-caddy:
runs-on:ubuntu-latest
needs:changes
# Только push: на workflow_dispatch человек просит полный деплой, и
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
else
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029). По этому каналу едет DEPLOY_SSH_KEY и выполняется git reset + перезагрузка Caddy на проде:MITM здесь переписывает конфиг прокси всех доменов. После переезда на Selectel (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку deploy.yml.'
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*