Compare commits

..

145 commits

Author SHA1 Message Date
811b9ecc8f Планировщик кита передаёт регион из строки расписания в обход (#3515)
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m55s
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / deploy (push) Successful in 1m58s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m46s
2026-09-13 11:45:55 +00:00
d33c97521d Merge pull request 'Две статьи на собственном замере вместо карточек «СКОРО»' (#3521) from feat/mera-articles-prices-waiting into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / test (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy / build-worker (push) Successful in 42s
Deploy / build-frontend (push) Successful in 43s
Deploy / build-backend (push) Successful in 45s
Deploy / deploy (push) Successful in 1m10s
Deploy / deploy-status (push) Successful in 3s
Deploy / perimeter-smoke (push) Successful in 1m46s
2026-09-13 11:45:33 +00:00
bot-backend
89d095f486 feat(mera): две статьи на собственном замере вместо карточек «СКОРО»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m27s
Раздел /articles с 29.08 стоял с одной живой статьёй и двумя карточками без
ссылок. Обе дописаны — но не по темам из макета: те темы данными не
обеспечены.

«Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений».
24 535 договоров Росреестра за июль 2025 — июнь 2026, регион 66. Медиана
123 626 ₽/м². Несущий факт статьи контринтуитивен и проверен: кривая цены
метра по комнатности не падает, а прогибается — дешевле всего метр в
двухкомнатной (116 910 ₽), дороже всего в студии (134 507 ₽). Плюс
поквартальная динамика за все десять доступных кварталов (+20,6%) и восемь
городов области с порогом публикации в сто сделок.

«Почему квартира не продаётся: что происходит с ценой, пока вы ждёте».
49 620 объявлений области с историей цены площадок. Цена двигалась у 43,5%,
из них вниз у 60,2%, вверх у 38,0%. Медиана снижения −3,85% и −291 000 ₽.

ПОЧЕМУ НЕ ТЕ ТЕМЫ, ЧТО В МАКЕТЕ. Карточка «Сколько продаётся квартира»
требовала срока экспозиции, а он не измеряется: `days_on_market` пуст у всех
435 тысяч сделок Росреестра, а снятие объявления и продажа в наших данных
неразличимы — 41% «снятий» приходится на шесть дат, совпадающих с провалами
собственного обхода. Мерили бы мы свой сборщик, а не рынок. Вторая карточка
называлась «Завысить и торговаться»; запрос «завысить цену на квартиру»
уведён юридическим смыслом (завышение суммы в ДКП ради ипотеки), и статья
попала бы в чужую выдачу.

ДИСЦИПЛИНА ЧИСЕЛ. Замер прошёл адверсариальную проверку: из десяти
первоначальных величин воспроизвелись две, остальные отброшены как
непригодные к печати. Обе статьи пересчитаны заново с нуля. У каждого блока
стоит своё поле source/footnote с датой, выборкой и границей — чем это число
НЕ является. Неизмеримое названо неизмеримым прямо в тексте, а не обойдено
молчанием: первый раздел второй статьи объясняет читателю, почему средний
срок продажи не назовёт никто честный.

Обвязка: два ключа в PUBLIC_ROUTES, две страницы, sitemap, три матчера в
apps.caddy на каждый адрес, PUBLIC_SHORT_PATHS у RouteGuard,
PUBLIC_INDEXED_FILES в гейте noindex. Блок «Читайте дальше» перестал быть
статичным «СКОРО» и делает карточку ссылкой, как только у неё появился
route. Витрина и лэндинг обновились сами — обе перебирают ARTICLE_CARDS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-13 14:41:10 +03:00
0af80b4395 Коэффициент перехода от цены предложения к цене сделки считается по каждому региону (#3518)
Some checks failed
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m48s
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 1m28s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
2026-09-13 11:38:26 +00:00
94d34e7dcb chore(tests): POSIX-only кейсы пропускаются на Windows и зарегистрированы в allowlist (#3520)
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 40s
Deploy / build-worker (push) Successful in 39s
Deploy / deploy (push) Successful in 1m11s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Failing after 1m43s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-13 11:17:13 +00:00
76a2963edc Локальный индекс считает медианы по своему региону, а статус точек интереса не врёт (#3517)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-13 11:14:44 +00:00
66a8c39ee4 Бэктест оценщика умеет любой регион, а не только Свердловскую область (#3516)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 1m19s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m48s
2026-09-13 11:03:31 +00:00
76bf9f1cf7 chore(caddy): заголовки безопасности для публичного домена Меры, листинг каталога превью выключен (#3519)
Some checks failed
Deploy Infra Host / sync-infra-host (push) Successful in 6s
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Has been skipped
Deploy / deploy-caddy (push) Failing after 4s
Deploy / perimeter-smoke (push) Has been skipped
Deploy / deploy-status (push) Failing after 2s
2026-09-13 11:03:15 +00:00
ed76ad8d43 fix(tradein): токен DaData не утекает в лог, скраббер больше не ломает access-log (#3514)
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m6s
Deploy Trade-In / build-backend (push) Successful in 1m31s
Deploy Trade-In / deploy (push) Successful in 1m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-13 10:52:02 +00:00
3218190a89 fix(tradein): стартовая проверка темы реально валидирует message_thread_id (#3513)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Successful in 1m58s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m48s
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m0s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-13 10:41:16 +00:00
8f06373e2f Merge pull request 'fix(mera): витрина сделок лэндинга — в расписание, дата прогона на страницу, монитор свежести (#3469)' (#3509) from fix/3469-showcase-schedule into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 3m47s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 2m37s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-09-12 17:12:30 +00:00
5ee208087d Импорт msk_raw умеет областной ДомКлик, а не падает на нём (#3512)
Some checks failed
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m39s
Deploy Trade-In / build-backend (push) Successful in 1m11s
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been cancelled
2026-09-12 17:00:30 +00:00
6ba04d845e Merge pull request 'МЕРА: у запросов к БД появился потолок по времени и по ожиданию блокировки (#3463)' (#3508) from fix/3463-db-statement-timeout into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 3m40s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
2026-09-12 16:38:34 +00:00
8863781589 Коллектор ДомКлика умеет Московскую область, а не только Москву (#3510)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
Deploy Trade-In / deploy (push) Successful in 59s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-09-12 16:34:32 +00:00
e6e7a8db1c fix(mera): живой тест витрины гоняется на полной схеме, а не только на пустой (#3469)
All checks were successful
CI / changes (pull_request) Successful in 12s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m52s
CI Trade-In / frontend-checks (pull_request) Successful in 1m17s
Тест применял 015/051/052 безусловно, и на базе, прошедшей всю цепочку
миграций (CI и прод), повтор 015 падал:

  psycopg.errors.UndefinedColumn: column "returning_count" of relation
  "scrape_runs" does not exist

Файл 015 идемпотентен относительно себя, но не относительно схемы,
прошедшей 214 (DROP COLUMN IF EXISTS returning_count): CREATE TABLE IF
NOT EXISTS — no-op, а COMMENT ON COLUMN в конце того же файла обращается
к снесённой колонке. На чистой базе, где 015 ложится с нуля, этого не
видно по построению — потому прогон и был зелёным там, где его гонял я,
и красным там, где его гоняет CI.

Зависимости теперь применяются только когда scrape_schedules ещё нет; в
докстроке — рецепт прогона на ПОЛНОЙ схеме, тем же путём, что у CI.

Заодно закрыты три дыры, которые находились мутациями:

- окно расписания и повторное применение проверяются на живой БД (до
  этого 6,7 → 6,23 и DELETE+INSERT вместо ON CONFLICT проходили насквозь);
  идемпотентность меряется created_at строки, а не числом строк — замена
  «удалить и вставить» тоже оставляет ровно одну строку, но стирает
  last_run_at/next_run_at на каждом деплое;
- next_run_at в будущем — утверждение стояло в приёмке и ничем не
  проверялось;
- handler сравнивается по САМОМУ job'у, а не по log_name: имя — второй
  литерал конструктора Handler, и чужое тело под верным ключом
  (_job_landing_stats) проходило проверку по имени.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:29:45 +05:00
68495907d5 Merge pull request 'fix(deploy): Caddy пересоздаётся только когда правка иначе не доедет (#3443)' (#3506) from fix/3443-caddy-selfdowntime into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 8s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 39s
Deploy / build-backend (push) Successful in 41s
Deploy / build-worker (push) Successful in 40s
Deploy / deploy (push) Successful in 1m8s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m40s
2026-09-12 16:16:03 +00:00
d5f0557ca8 fix(deploy): сверка маунтов Caddy не может провалиться втихую (#3443, ревью)
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m8s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 17m40s
Две дыры из deep-ревью PR #3506 — обе про «отказ выглядит как успех».

M1. `stale=$(docker inspect … | while …)` под `set -eu` без `pipefail`
(в POSIX-sh его нет) отдаёт статус `while`, то есть всегда 0. Провал
`docker inspect` или пустой вывод давали пустой список → ветка «всё
доехало» → `caddy reload` → зелёная джоба с надписью «окна недоступности
нет» при прокси, работающем по СТАРОМУ конфигу. Ровно тот беззвучный
отказ, ради которого написан скрипт. Теперь список читается отдельной
командой, провал и пустой вывод считаются расхождением (fail-safe в
прежнее поведение), число сверенных файлов печатается — «сверили пять» и
«сверили ноль» в логе больше не выглядят одинаково. Ноль пофайловых
маунтов (например, если Caddyfile переведут на именованный том) — тоже
расхождение, а не тавтологически успешная сверка.

M2. В фильтре `backend` (ci.yml) не было `ops/**`, а все содержательные
регрессии живут в самом ops/caddy-apply.sh: гейт его ИСПОЛНЯЕТ. PR,
правящий только скрипт, давал backend=false — джоба пропускается, гейт не
исполняется, «пересоздавать всегда» уезжает в main зелёным. Тот же класс,
что уже осуждён комментариями рядом (#2950/#3448/#3467).

Мелочи оттуда же:
* `[ -d "$src" ] && continue` вместо `[ -f "$src" ] || continue` — пропуск
  по `-f` склеивал «это каталог» (пропустить верно) и «файла на хосте
  нет», для которого в контейнере как раз живёт старый инод;
* сообщение об отказе `caddy validate` больше не называет причиной
  битый конфиг, когда упасть мог и сам запуск проверочного контейнера;
* в комментарии к проверке записана её граница: в полном деплое общий
  `up -d $UP_SERVICES` (deploy.yml:959) поднимает и caddy за ~110 строк
  до вызова скрипта, поэтому правка, которая одновременно ломает Caddyfile
  и меняет блок caddy в compose, пересоздаст контейнер раньше проверки.

Гейт дорос с 16 до 22 проверок: `docker inspect` не ответил → пересоздание,
ноль пофайловых маунтов → пересоздание, исчезнувший файл на хосте →
пересоздание, число сверенных маунтов печатается, `caddy reload`/вызов
скрипта не проглочены `|| true`, ci.yml-фильтр покрывает ops/**.
Все 11 мутантов (7 новых + 4 прежних) краснеют, контроль зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:49:34 +05:00
70bb5a3a8f tradein: тот же потолок второму движку + тесты, которые ловят испорченное значение (#3463)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Правки по deep-ревью PR #3508.

1. HIGH. `app/core/auth_db.py` строил второй движок БЕЗ `connect_args`, а моё
обоснование пропуска было ложным: на проде `IDENTITY_STORE=auth` во всех трёх
сервисах образа и `AUTH_DB_PASSWORD` задан (сверено `printenv` в контейнерах),
то есть реестр живой. Путь горячий: `core/rbac.py` резолвит session-cookie в
middleware, синхронно на event loop'е, на каждом запросе с cookie — значит
`ACCESS EXCLUSIVE` на `auth.sessions` вешал бы не четыре слота `/estimate`, а
весь uvicorn-воркер (он один), включая `/health`. Потолок — та же константа
`DB_CONNECT_ARGS`: одна на оба движка, а не защита на одном и мина на втором.
Срабатывание безопасно — вызов уже под `except Exception` с фолбэком.

2. MEDIUM. Испорченный `options` (`statement_timeout=30000zz`) проходил ЗЕЛЁНЫМ:
статическая проверка искала ПОДСТРОКУ (а `…=30000` — префикс испорченного), а
живая глушила отказ коннекта голым `except` → skip → запись в allowlist. На
проде это `FATAL: invalid value for parameter` на КАЖДОМ коннекте, то есть
полный отказ продукта при зелёном сьюте. Теперь: сравнение `options` на
РАВЕНСТВО, и `_live_engine` сначала пробует коннект БЕЗ `connect_args` — сервера
нет это пропуск, а «сервер есть, наши options он не принял» это падение.

3. LOW. У проверки согласованности был пол и не было крыши: `300_000` (пять
минут) зеленел. Добавлена симметричная граница `<= 2 ×` самого длинного
объявленного бюджета.

4. LOW. Три факта в комментариях исправлены:
   * `pg_stat_statements` НЕ опора по планировщику — вытесняет записи с calls=1
     (`dealloc` вырос за десять минут, из топа пропал `REFRESH MATERIALIZED VIEW`
     30.85 с). Основная опора — `scrape_runs`;
   * самый длинный set-based statement через движок — матч ГАР→houses: 2.07 с с
     городским фильтром и 6.46 с без. Запас ~3×, а не 7×;
   * `idle in transaction` 29 с — это tgbot (`services/tgbot/bridge.py`,
     транзакция поверх long-poll Telegram; сверено 3 пробами: одна и та же
     сессия, `SELECT value FROM tg_support_state …`), а не свипы. Решение не
     ставить потолок на простой от этого только крепче.

Мутационная проверка (обе лэйны краснеют на каждой): испорченный `options`,
`_STATEMENT_TIMEOUT_MS = 300_000`, снятый `connect_args`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:35:38 +05:00
59482cae85 fix(mera): витрина сделок в расписании, дата прогона на странице (#3469)
Some checks failed
CI Trade-In / frontend-checks (pull_request) Successful in 1m57s
CI Trade-In / backend-tests (pull_request) Failing after 5m27s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Задача landing_showcase_deals не запускалась вообще: строки в
scrape_schedules не было (0 строк по '%showcase%' на проде 12.09), а в
реестре product_handlers — обработчика. Пересчёт был ручным шагом, и
лэндинг показывал прогон от 30.08 — тринадцать суток.

Что сделано:

- Handler `landing_showcase_deals` в product_handlers: тело задачи
  писалось под `python -m` и про run_id не знает, поэтому done/failed
  ставит обработчик (как у refresh_search_matview).
- Миграция 303 сеет расписание: enabled=true, окно 06:00–07:00 UTC,
  interval_days=1. Такт суточный не из-за данных — сделки Росреестра
  квартальные, — а из-за кода: прогноз считает тот же спайн оценщика,
  что и боевой расчёт, и любой деплой меняет числа на витрине, не
  трогая ни одной сделки.
- Эта же строка заводит витрину в СУЩЕСТВУЮЩИЙ монитор свежести:
  сводка просроченных источников (emit_stale_digest, #2670) ходит по
  включённым расписаниям и бьёт ERROR → GlitchTip, когда источник
  молчит дольше 3× своего такта. Своего монитора не заводим: витрина
  была невидима не потому, что сводка не умеет про неё говорить, а
  потому, что источника для сводки не существовало.
- На странице под таблицей — дата прогона рядом со счётчиками:
  «Витрина пересчитана 30.08.2026». computed_at ручка /showcase отдавала
  и раньше, фронт его не показывал; даты нет — предложения нет.

Тесты: обработчик резолвится тем же resolve_handler, что и боевой
_dispatch; миграция на живой БД реально кладёт строку и не задваивает
её при повторе; настоящий запрос сводки видит витрину и отдаёт её
просроченной после 3× такта (число тактов — литерал из приёмки, не
константа кита: взятое из неё ожидание уезжало вместе с ней и держало
тесты зелёными при факторе 3650).

Closes #3469

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:05:19 +05:00
5d46e59bdc Московская область считается по своему ряду Сбериндекса (#3507)
All checks were successful
Deploy Trade-In / test (push) Successful in 3m51s
Deploy Trade-In / build-backend (push) Successful in 1m21s
Deploy Trade-In / deploy (push) Successful in 1m35s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-09-12 14:50:01 +00:00
a8ba2d5d3c Импорт сырья msk_raw умеет Московскую область, а не только Москву (#3505)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-12 14:49:06 +00:00
a8503d3a58 tradein: потолок на запрос и на ожидание блокировки для движка БД (#3463)
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
На боевой БД `statement_timeout`, `lock_timeout` и
`idle_in_transaction_session_timeout` равны 0, а у движка `app/core/db.py` не
было `connect_args` вовсе. После #3444/#3449 шаги БД на пути `/estimate` идут
через обёртку, которая при отмене по бюджету ДОЖИДАЕТСЯ своего потока (иначе он
остаётся сиротой в общей `Session`) — ожидание верное, но его верхняя граница
равна длительности самого запроса, а у запроса границы не было. Один
`ACCESS EXCLUSIVE` на таблице → четыре повисших запроса → `_ESTIMATE_CONCURRENCY`
исчерпан → `/estimate` отдаёт 429 всем остальным.

Потолок ставится на КОННЕКТЕ (libpq `options`), а не в обёртке: таймаут в
обёртке вернул бы ровно ту сироту, ради которой писался #3449.

statement_timeout = 30 с: выше самого длинного ОБЪЯВЛЕННОГО бюджета `/estimate`
(20 с, `estimate_avito_imv_timeout_s`) в 1.5 раза и в 7 раз выше самого долгого
ЗАМЕРЕННОГО запроса через этот движок (4.27 с, `pg_stat_statements` на проде за
16 суток), но конечен. lock_timeout = 5 с: та же величина, что у миграций
проекта, и больше `deadlock_timeout` (1 с на проде).

`idle_in_transaction_session_timeout` намеренно не трогаем: тем же движком живёт
планировщик, а его свипы держат транзакцию открытой всё время внешнего HTTP
(замер: живая сессия `idle in transaction` 29 с).

Задачи планировщика проверены, а не предположены: у `listing_source_snapshot`
свой `SET LOCAL statement_timeout = 900000`, и тест доказывает, что `SET LOCAL`
ПЕРЕКРЫВАЕТ сессионный потолок и не течёт за свою транзакцию. Самая долгая
чисто-БД задача по `scrape_runs` за 14 суток укладывается в 9.7 с целиком;
единственный запрос длиннее 20 с на всей БД (`REFRESH MATERIALIZED VIEW
CONCURRENTLY`, 30.85 с) идёт мимо движка — по своему сырому psycopg-соединению.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 19:46:57 +05:00
9fedaa4582 fix(deploy): Caddy пересоздаётся только когда правка иначе не доедет (#3443)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m31s
Полный деплой ПТИЦЫ каждый раз делал `up -d --force-recreate --no-deps caddy`
и сносил единственный процесс, слушающий 80/443: замер 05.09 (#3274) — 67 с
`code=000` на ВСЕХ доменах хоста, включая публичный лендинг МЕРЫ. Заглушка
окна деплоя здесь бессильна по построению: её отдаёт тот же Caddy.

Что установлено, а не принято на веру:

* Безусловный флаг появился 17.05 (11e78d73) ради нового bind-маунта
  `./preview` — «иначе новые volume mounts не появляются». Довод неверен:
  `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам при
  смене описания сервиса или образа. Проверено на живом демоне (docker 28.4):
  добавлен volume → новый id; тег переставлен на другой образ → новый id;
  не менялось ничего → `Container … Running`, id тот же.
* Compose не видит только одного: СОДЕРЖИМОГО пофайлового bind-маунта.
  `git reset --hard` пишет новый инод, контейнер держит прежний, и `caddy
  reload` перечитывает старый текст (тот же механизм — Alertmanager 27.08 и
  Alloy #3380). У Caddy так смонтированы пять путей: Caddyfile и четыре
  сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают —
  самый частый случай, caddy/sites/apps.caddy, пересоздания НЕ требует.
* Отсюда же второй, беззвучный дефект: быстрый путь `deploy-caddy` делал голый
  `exec caddy reload` после `git reset --hard`, то есть правка Caddyfile или
  сниппета до контейнера не доезжала вовсе, а джоба уходила зелёной.

Оба пути деплоя теперь зовут ops/caddy-apply.sh: `caddy validate` одноразовым
контейнером по файлам С ХОСТА (битый конфиг не применяется и прокси не
трогает) → `up -d` без `--force-recreate` → если контейнер тот же, сверка
sha256 каждого пофайлового маунта с тем, что видит контейнер → пересоздание
ТОЛЬКО при расхождении, иначе `caddy reload` без разрыва соединений.
Не прочиталось — считаем расхождением: fail-safe в сторону прежнего поведения.

Гейт backend/tests/ops/test_3443_caddy_reload_not_recreate.py исполняет скрипт
с подставным `docker` и смотрит на совершённые действия, а не на его текст:
ничего не менялось → reload без пересоздания; правлен Caddyfile или любой из
сниппетов → пересоздание; правка в каталоге → без пересоздания; битый конфиг →
не тронуто ничего; compose пересоздал сам → второго пересоздания нет.
Отдельно — проводка в deploy.yml и запрет безусловного `--force-recreate` для
Caddy в полном деплое.

Приёмка (#3443) снимается ПОСЛЕ мержа, на живом деплое: непрерывная проба
`scripts/probe-deploy-window.sh` с хоста — максимальная серия `000` меньше 2 с
против нынешних 67 с.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 19:38:48 +05:00
c265e1f769 Расписание импорта сделок Росреестра по Московской области (#3504)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m40s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m12s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-12 14:18:57 +00:00
162e101636 perf(tradein-tests): убрать реальные паузы из тестов, волна 2 (#3503)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-12 14:15:21 +00:00
98a582e242 perf(tests): убрать фиксированные ожидания из analyze-тестов Site Finder (#3502)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 39s
Deploy / build-worker (push) Successful in 40s
Deploy / deploy (push) Successful in 1m12s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m41s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-12 14:09:23 +00:00
cef872ace1 Московская область в реестре регионов, обход — по специфичности вместо кода (#3501)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m34s
Deploy Trade-In / test (push) Successful in 4m9s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
Deploy Trade-In / build-backend (push) Successful in 1m19s
2026-09-12 13:59:43 +00:00
1f3585c136 perf(tradein-tests): убираем реальные паузы из 5 медленных тестов (#3500)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 2m21s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Successful in 3m47s
Deploy Trade-In / build-backend (push) Successful in 35s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s
2026-09-12 13:46:29 +00:00
ba2eb3b149 Merge pull request 'fix(tgbot): проверка темы при старте + общий rate limit на группу (#3471)' (#3494) from feat/3471-tg-topic-check-and-group-rate-limit into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / test (push) Successful in 4m28s
Deploy Trade-In / build-backend (push) Successful in 2m0s
2026-09-12 13:37:09 +00:00
a6619e1d3f Merge pull request 'Прогон тестов перестаёт спать по-настоящему' (#3499) from fix/3471-tests-no-real-sleep into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-12 13:36:10 +00:00
bot-backend
120526e313 fix(tests): убрать настоящий сон дослальщика алертов из прогона
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m24s
Фоновый дослальщик GlitchTip спит по настоящим часам: три попытки по
тридцать секунд. TestClient ждёт завершения background-задачи, привязанной
к ответу, поэтому один тест на отказ доставки держал весь прогон минуту, а
в CI прогон умирал по таймауту без единой строки об ошибке — набор
выглядел «медленным», хотя на деле висел.

Проверять надо, что дослальщик вызван и сколько раз, а не то, что
интерпретатор умеет спать. Файл тестов вебхука: было зависание, стало
19 passed за 1.36с.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 16:15:36 +03:00
bot-backend
8142834555 fix(tgbot): stop CI-hanging busy-spin in rate-limit test, isolate shared client in tests
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 7m27s
Root cause of the red PR #3494 CI job (9% progress, 75s life, no error line):
test_send_message_rate_limits_across_different_topics_same_chat mocked
asyncio.sleep as a pure no-op without advancing time.monotonic. The 3rd send
(over the test's limit=2) entered TelegramGroupRateLimiter.acquire(), which
recomputes wait_s from the real, unmocked clock every iteration - since the
fake sleep never advances it, the window never expires and the while-loop
busy-spins forever instead of actually waiting, until pytest-timeout kills it.
Fixed by advancing a fake monotonic clock inside fake_sleep, matching the
already-correct pattern used by the other tests in this file.

Also added _reset_telegram_shared_client (tests/conftest.py, same pattern as
_reset_estimate_rate_limiter): app.services.tgbot.shared._client is a
module-level singleton whose rate limiter otherwise accumulates real
wall-clock timestamps across the whole pytest session, not per test.

Documented honestly in config.py: the API-role budget is shared between
support web-chat mirrors and GlitchTip alerts with no priority between them,
so a large alert burst can make the web-chat wait out its own timeout and
return 502 - flagged as a known follow-up, not fixed here.

NOTE: a full `pytest -q --timeout=60` run still hangs further into the suite,
at tests/test_glitchtip_webhook.py::test_telegram_failure_returns_502_not_500.
Not root-caused within this session's budget - the test's _fake_telegram_client
fixture correctly monkeypatches glitchtip_module.get_telegram_client, but the
anyio worker thread running the ASGI request is seen parked in a real
event-loop poll/select wait, consistent with an actual (non-mocked) sleep
somewhere in that path. Needs a follow-up session with a fresh time budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 16:10:27 +03:00
3ed759da7d Ряд Московской области в Сбериндексе — грузим заранее, до включения региона 50 (#3498)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 6m23s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 2m4s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
2026-09-12 12:56:37 +00:00
bot-backend
acbfdf0a18 Merge remote-tracking branch 'forgejo/main' into feat/3471-tg-topic-check-and-group-rate-limit
Some checks failed
CI / changes (pull_request) Successful in 13s
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 2m27s
2026-09-12 15:34:28 +03:00
4c8c02cce5 Merge pull request 'fix(tradein): идемпотентная отправка сообщения в поддержку (#3471)' (#3495) from feat/3471-support-send-idempotency into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Successful in 2m17s
Deploy Trade-In / test (push) Successful in 6m37s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 7m49s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
2026-09-12 12:33:08 +00:00
bot-backend
8e7c65061b fix(tgbot): honest H1 rejection, per-role H2 budget, M1/M2/L1 cleanup (#3471 review)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 2m29s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deep review of PR #3494 found the rate limiter unusable as designed:
- H1: acquire() waited unbounded even for interactive HTTP handlers (support.py,
  glitchtip.py already pass a narrow `timeout` — reuse it as the queue wait cap
  instead of editing those handlers, which are out of scope here). New
  TelegramRateLimitedError (subclass of TelegramError) gives a fast, honest
  502 instead of hanging past the caller's own budget.
- H2: the limiter is per-process (in-memory), but two processes write to the
  same group (uvicorn API + bot worker) — giving each the same 18/min doubled
  the platform ceiling. Split into telegram_group_rate_limit_api_per_minute
  (12) and _bot_per_minute (6), sum kept below ~20.
- M1: bridge.py sends without an explicit timeout inherited "wait forever",
  stalling the single-threaded poll loop (open DB session) past the SIGTERM
  drain window. Bounded via rate_limit_max_wait=20s at the six call sites.
- M2: _locks/_sent_at grew unbounded on every unique DM chat_id. Added
  opportunistic cleanup of fully-expired entries.
- L1: the "queue full" warning now logs once per acquire() call, not once
  per sleep iteration.
- Corrected a factual error in the docstring: TELEGRAM_SUPPORT_CHAT_ID and
  TELEGRAM_ALERTS_CHAT_ID are the SAME group on prod (topics differ only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 15:28:16 +03:00
dfe7910bb7 ДомКлик как четвёртый источник переливки московского сырья в listings (#3497)
Some checks failed
Deploy Trade-In / test (push) Successful in 6m28s
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-09-12 12:26:09 +00:00
bot-backend
d5c876e3d0 fix(tradein): включаем idempotency-key на фронте, чиним assert-crash и честность докстрингов (#3471)
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 7m35s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m10s
Ревью PR #3495 нашло, что механизм был мёртвым кодом: фронт не отправлял
Idempotency-Key ни в одном запросе, весь прод-трафик шёл по ненадёжному
fallback-отпечатку. Плюс два "assert" в record_inbound после ON CONFLICT
давали AssertionError (не ловится except SQLAlchemyError) уже ПОСЛЕ
доставки в Telegram — под `python -O` assert и вовсе исчезает.

- useSupportChat.ts: useSendSupportMessage генерирует Idempotency-Key
  (crypto.randomUUID()) на намерение отправить, переиспользует его при
  повторной отправке ТОГО ЖЕ текста, сбрасывает на успехе.
- web_support_storage.record_inbound: assert -> явные ветки с логом;
  логируем отброшенный topic_message_id проигравшего гонку (не молча).
- Докстринги/комментарии переписаны честно: что именно закрывает
  pre-check (ответ клиенту потерян / двойной клик после успеха), а что
  НЕ закрывает (сетевую потерю на плече Selectel -> Telegram — там
  сообщение просто не доставлено, повтор это законная первая попытка).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 15:24:21 +03:00
628f59dcc0 ДомКлик — четвёртая площадка ручного сбора Москвы (#3496)
All checks were successful
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m1s
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-12 12:05:18 +00:00
bot-backend
1b595a0091 fix(sql): lock_timeout у миграции ключа идемпотентности
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 7m56s
Проверка миграций в CI требует его для блокирующего DDL: без ограничения
ALTER встаёт в очередь за чужой сессией и уводит за собой все последующие
обращения к таблице (#2752).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 15:02:23 +03:00
bot-backend
6433477f7c fix(tgbot): проверка темы при старте + общий rate limit на группу (#3471)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 8m55s
Бот падал молча на каждом сообщении, если тему форума удалили/переименовали:
узнавали об этом только по отсутствию сообщений у людей. tgbot_main теперь
один раз на старте проверяет getChat + typing-индикатор с message_thread_id
(единственный способ Bot API провалидировать message_thread_id без создания
видимого сообщения) и громко пишет error при отказе, не роняя процесс.

Второе: лимит Telegram (~20 msg/min) общий на всю группу, все темы делят
бюджет — всплеск GlitchTip-алертов вместе с потоком поддержки в ту же группу
уже давал 429 и терял сообщения. TelegramGroupRateLimiter — скользящее окно
per-chat_id (НЕ per-теме) с asyncio.Lock на чат, встроен прямо в
TelegramClient._request перед _post, поэтому считает все отправки независимо
от relay/прямого пути и без изменений в support.py/glitchtip.py (они уже идут
через общий клиент). Порог настраивается через
TELEGRAM_GROUP_RATE_LIMIT_PER_MINUTE (дефолт 18, чуть ниже потолка площадки).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:59:54 +03:00
bot-backend
091befc9ff fix(tradein): идемпотентная отправка сообщения в поддержку (#3471)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Failing after 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 8m0s
Сеть Selectel -> api.telegram.org теряет заметную долю коротких запросов,
поэтому браузерный ретрай/двойной клик/переотправка по таймауту при отправке
в веб-чат поддержки создавали ВТОРУЮ строку в web_support_messages И второе
зеркало в support-топике Telegram, а не только дубль в БД.

Ключ идемпотентности (миграция 301, колонка idempotency_key +
partial unique индекс (thread_id, idempotency_key) WHERE direction='in'):
- явный заголовок Idempotency-Key от клиента, если он есть и валидной формы;
- иначе детерминированный fallback-отпечаток sha256(identity|текст|минутное
  окно) — старые клиенты без заголовка продолжают работать без изменений.

Pre-check резолвит тред по identity и ищет существующее inbound-сообщение с
этим ключом ДО похода в Telegram (не только до записи в БД) — иначе повтор
всё равно отправил бы второе зеркало, даже если бы вторая строка в БД не
создавалась. Гонку двух одновременных запросов с одним ключом закрывает
INSERT ... ON CONFLICT DO NOTHING на уникальном индексе в
web_support_storage.record_inbound (не read-then-write), а не сам pre-check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:59:52 +03:00
994eb79323 Merge pull request 'Ожидаемые исходы скрапинга перестают быть ошибками' (#3488) from fix/3471-scraper-log-levels into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 2m32s
Deploy Trade-In / test (push) Successful in 6m37s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 2m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-12 11:48:44 +00:00
192e10d32d Merge pull request 'fix(metrics): гасим crash-loop tg-relay через профиль relay' (#3492) from fix/3471-relay-secret-wiring into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Deploy Metrics / server (push) Successful in 44s
Deploy Metrics / agent-apps (push) Failing after 20s
Deploy Metrics / agent-infra (push) Successful in 30s
2026-09-12 11:48:40 +00:00
bot-backend
4b74356c60 fix(metrics): force-recreate alert-ack/tg-relay после up -d — код монтируется с хоста
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
up -d сравнивает описание сервиса, не содержимое бинд-маунта. alert-ack и
tg-relay получают app.py именно бинд-маунтом (не сборкой образа), поэтому
правка файла не пересоздаёт уже работающий контейнер — он продолжает
исполнять старый код в памяти интерпретатора.

Подтверждено на проде 12.09.2026: PR #3490 (фикс alert-ack) слился, файл на
диске обновился (git reset --hard), а gendesign-alert-ack, запущенный 25
минут назад, отвечал по старой логике. Помог только ручной docker restart.

force-recreate для обоих сервисов сделан условным по PROFILES (case
",$PROFILES,"), чтобы не падать на несуществующем контейнере, когда
профиль alerts/relay в этом прогоне не включён.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:47:17 +03:00
bbf70a3283 Merge pull request 'Продуктовые счётчики и дашборд воронки' (#3491) from feat/3471-product-metrics-dashboard into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 15s
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy Metrics / agent-apps (push) Failing after 18s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Metrics / agent-infra (push) Successful in 30s
Deploy / build-backend (push) Successful in 2m22s
Deploy Trade-In / test (push) Has been cancelled
Deploy / build-worker (push) Successful in 3m31s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m41s
2026-09-12 11:45:49 +00:00
bot-backend
347342bb5c fix(metrics): гасим crash-loop tg-relay пустым секретом через профиль relay
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
PR #3487 добавил сервис tg-relay без profiles: контейнер поднимался
всегда и падал в SystemExit на пустом TG_RELAY_SECRET (на проде
подтверждён Restarting в бесконечном цикле).

- deploy-metrics.yml: TG_RELAY_SECRET прокинут в ssh-action по образцу
  ALERT_ACK_GLITCHTIP_SECRET; профиль relay включается независимо от
  alerts, только когда секрет непуст; ::warning на пустом секрете.
  Сравнение PROFILES с "alerts" переведено на case, иначе комбинация
  "alerts,relay" сломала бы прежнюю точную строковую проверку.
- docker-compose.metrics.yml: tg-relay получил profiles: ["relay"].
- tradein-mvp/docker-compose.prod.yml: комментарий у tgbot — deploy-tradein.yml
  секреты приложения в CI не инжектит, TELEGRAM_RELAY_BASE_URL и
  TELEGRAM_RELAY_SECRET на продуктовом хосте заводятся так же, как
  прочие TELEGRAM_* — строкой в user-managed runtime-файле окружения
  backend на хосте, без правок workflow (существующий механизм этого
  файла, см. README-АДМИНУ.md).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:44:56 +03:00
8f10ded2a0 Merge pull request 'Быстрый путь «правка только прокси» наконец включается: считаем изменённые файлы сами (#3448)' (#3465) from fix/3448-caddy-only-detection into main
Some checks failed
Deploy / changes (push) Successful in 9s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 54s
Deploy / build-worker (push) Successful in 54s
Deploy / build-frontend (push) Successful in 54s
Deploy / deploy (push) Has been cancelled
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
2026-09-12 11:43:38 +00:00
bot-backend
6608fd5c70 test(scrapers): поднять caplog-фильтры под error->warning штатных исходов
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 8m2s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Ветка fix/3471-scraper-log-levels понизила error на warning для штатных
исходов скрапинга (пустой/исчерпанный пул прокси, серия подтверждённых
блоков площадки) -- 7 тестов фильтровали caplog по ERROR и падали на
пустом списке. Поправлен только уровень фильтра/set_level, содержательные
assert'ы (streak vs ratio, отсутствие qrator/ip_rate_limited литералов,
различимость текстов "исчерпан" и "пуст") не менялись.

В test_exhausted_and_empty_pool_log_texts_are_distinct оба сценария
(пустой пул и fail-closed) теперь на одном уровне (warning) -- тест
адаптирован проверять различимость по тексту, а не по уровню.

Refs #3471

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:39:58 +03:00
ede6fd5974 Merge pull request 'Продуктовый Telegram-трафик уходит через ретранслятор на Beget' (#3487) from feat/3471-telegram-relay-beget into main
Some checks failed
Deploy Trade-In / test (push) Successful in 6m53s
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Infra Host / sync-infra-host (push) Successful in 7s
Deploy / changes (push) Successful in 13s
Deploy Trade-In / changes (push) Successful in 18s
Deploy Metrics / server (push) Successful in 25s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 45s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-frontend (push) Successful in 49s
Deploy / build-backend (push) Successful in 51s
Deploy Metrics / agent-apps (push) Failing after 15s
Deploy Metrics / agent-infra (push) Successful in 25s
Deploy / deploy (push) Successful in 1m13s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m46s
2026-09-12 11:38:25 +00:00
bot-backend
70b1419cde Merge remote-tracking branch 'forgejo/main' into fix/3471-scraper-log-levels 2026-09-12 14:36:08 +03:00
d74121d74c Merge pull request 'Отказ в alert-ack перестаёт съедать следующий алерт' (#3490) from fix/3471-alert-ack-drain-body into main
Some checks failed
Deploy Metrics / server (push) Successful in 19s
Deploy Metrics / agent-apps (push) Failing after 11s
Deploy Metrics / agent-infra (push) Successful in 22s
2026-09-12 11:34:30 +00:00
694bf13d3d Merge pull request 'Очередь задач и Redis становятся видимыми' (#3489) from feat/3471-celery-redis-metrics into main
Some checks failed
Deploy Metrics / server (push) Successful in 19s
Deploy Metrics / agent-apps (push) Failing after 12s
Deploy Metrics / agent-infra (push) Successful in 26s
2026-09-12 11:27:22 +00:00
bot-backend
24c2052057 style(tg): перенос длинной строки заголовков ретранслятора
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m58s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:27:10 +03:00
2edaae148f Merge pull request 'Ответ оператора не исчезает при сбое БД, и реплай на собственный ответ снова маршрутизируется' (#3479) from fix/3471-bridge-db-failure-reply-loss into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 6m48s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Has been cancelled
2026-09-12 11:26:29 +00:00
bot-backend
4d3e273405 fix(ops): alert-ack вычитывает тело запроса до любой ветки отказа
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Соединение переиспользуется (protocol_version = HTTP/1.1), и Caddy перед
сервисом держит пул к апстриму. Ответ 401/404/503 без чтения тела оставлял
его в сокете, и следующий запрос по тому же соединению начинался с чужих
байт.

Поймано на проде: зонд без секрета получил 401, а следующий запрос — уже с
верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
То есть один отказ съедал следующий НАСТОЯЩИЙ алерт, ровно в том канале,
который заводился как резервный.

Тело теперь читается один раз в начале do_POST и передаётся вниз. Четыре
теста поднимают настоящий сокет и шлют пару запросов по одному соединению —
на прежнем коде три из них падают с той же строкой 501.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:25:44 +03:00
382f266801 Merge pull request 'Алерт GlitchTip переживает недоступность Telegram: отправка уходит в фон, 502 остаётся' (#3485) from feat/3471-glitchtip-alert-queue into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 19s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-12 11:22:45 +00:00
bot-backend
690f1ef5d2 feat(metrics): продуктовые счётчики Prometheus для Меры и Птицы + дашборд
All checks were successful
CI / backend-tests (pull_request) Successful in 17m59s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m18s
CI Trade-In / backend-tests (pull_request) Successful in 5m59s
Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:

Мера (tradein-mvp/backend/app/observability/metrics.py):
- mera_estimates_total{outcome=ok|insufficient_data} — POST /estimate,
  зеркалит user_events.event_type=estimate_request (294 строки в БД),
  insufficient_data — не ошибка, а исход без аналогов.
- mera_address_suggestions_total{found=yes|no} — GET /geocode/suggest,
  своего user_events-события у ручки не было.
- mera_reports_exported_total (без лейблов) — GET /estimate/{id}/pdf.
- mera_leads_total (без лейблов) — POST /trade-in/lead.
- mera_support_messages_total{channel=web|anon} — POST /support/messages
  и /support/anon/messages, счётчик после успешной доставки в Telegram.
- mera_logins_total{result=success|failed} — рядом с user_events
  login_success/login_failed в auth.py (97/453 строк в БД).

Птица (backend/app/observability/metrics.py):
- sitefinder_reports_exported_total{format} — GET .../forecast/export
  (md/json/tg/docx/pptx/pdf) и POST .../best-layouts/pdf.

Метки везде — фиксированный литерал из места вызова (outcome/found/channel/
result/format), никогда username/адрес/estimate_id/кадастровый номер —
это ровно то, что взрывает кардинальность ряда у Prometheus.

Дашборд ops/metrics/grafana/dashboards/product.json ("Продуктовые метрики",
uid gendesign-product) — воронка Меры (оценки/подсказки/лиды/отчёты/входы/
поддержка) + экспорт форматов Птицы, часовые increase()-панели без
стекирования (на соседней панели оно уже давало ложную тревогу, PR #3474).
Provisioning тот же, что у apps.json — сканирует директорию, отдельного
конфига не нужно.

ops/metrics/alloy/alloy-apps.alloy проверен: у job "apps" нет relabel-
фильтра по __name__ (в отличие от cadvisor) — новые счётчики уходят в
remote_write как есть, правки не потребовалось.

Refs #3471
2026-09-12 14:22:33 +03:00
05959464ae ci.yml: запускать backend-tests на правках метрик (#3467/#3475)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 19s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 2m48s
CI / backend-tests (pull_request) Successful in 18m27s
Гейт backend/tests/ops/test_3467_prometheus_reload.py (едет в PR #3475) читает
.forgejo/workflows/deploy-metrics.yml и docker-compose.metrics.yml. Пока этих
путей нет в фильтре `backend`, правка, трогающая ТОЛЬКО deploy-metrics.yml —
например дописывающая `|| true` к шагу перезагрузки Prometheus, — даёт
backend=false: джоба backend-tests пропускается, гейт не исполняется, регрессия
уезжает в main зелёной. Это ровно тот класс, который осуждает комментарий
двумя абзацами выше в этом же файле: гейт, который не запускается на той самой
правке, от которой стережёт, — украшение.

Список правится одной веткой намеренно: параллельный PR #3475 его не трогает,
иначе две ветки подрались бы за один фильтр.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:20:16 +05:00
bot-backend
34c15ecf08 feat(ops): маршрут ретранслятора Telegram на metrics.gendsgn.ru
Some checks failed
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m39s
Без маршрута сервис tg-relay недостижим снаружи и настройка
TELEGRAM_RELAY_BASE_URL на продуктовом хосте ни к чему не приводит.

Путь несёт токен бота, поэтому помечен log_skip: иначе он осядет в
файловом логе сайта и в stdout-копии, которую читает Alloy (#3154).
Таймаут ответа поднят до 80с — getUpdates висит long-poll'ом до ~40с,
дефолтного не хватает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:19:58 +03:00
bot-backend
9a93e575cc Merge remote-tracking branch 'forgejo/main' into feat/3471-telegram-relay-beget 2026-09-12 14:18:55 +03:00
c826793a2a Merge pull request 'Меньше шума: выключенные платежи не засоряют ленту ошибок, провал прокси-пробы пишется строкой вместо трейса' (#3483) from fix/3471-observability-noise into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-12 11:18:33 +00:00
127c9a5c2a Merge pull request 'Деплой метрик перечитывает конфиг Prometheus, а не только кладёт его на диск' (#3476) from fix/3467-metrics-deploy-reload into main
All checks were successful
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 32s
Deploy / build-backend (push) Successful in 51s
Deploy / build-worker (push) Successful in 58s
Deploy Metrics / agent-infra (push) Successful in 29s
Deploy Metrics / agent-apps (push) Successful in 31s
Deploy / deploy (push) Successful in 1m18s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m46s
2026-09-12 11:18:28 +00:00
bot-backend
62a560387c feat(ops): измеряем очередь Celery и Redis, до сих пор слепая зона
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Prometheus не видел ни одной серии celery_*/redis_* — переполнение
очереди Site Finder и залипший воркер снаружи выглядели одинаково,
тишиной (issue #3471).

Добавлено (только на продуктовом хосте, профиль apps):
- redis-exporter (oliver006/redis_exporter) — здоровье общего Redis
  (db0 celery-брокер Site Finder, db1 SearchCache trade-in, db2
  glitchtip), адрес через alias gendesign-redis на сети shared, без
  нового сетевого доступа.
- celery-exporter (danihodovic/celery-exporter) — глубина очереди,
  число живых воркеров, счётчик неуспешных задач. Выбран вместо
  redis-exporter --check-keys, потому что дефолтная очередь "celery"
  дала бы только глубину, но не воркеров и не failures.
- Скрейп обоих в alloy-apps.alloy.
- Алерты в infra.yml: RedisDown, NoActiveCeleryWorkers,
  CeleryQueueGrowing (порог 150 предварительный — реальных данных по
  глубине очереди ещё нет, пересмотр через неделю наблюдений).

Имена метрик celery-exporter (celery_queue_length, celery_worker_up,
celery_task_failed_total) — по документации проекта, без прогона на
реальном брокере; сверить после первого деплоя, см. комментарий у
сервиса. promtool check rules — 23 правила, SUCCESS.

Refs #3471
2026-09-12 14:16:24 +03:00
bot-backend
e0564d12fe feat(tg): продуктовый Bot API трафик уходит через ретранслятор на Beget
Замер 12.09.2026, оба хоста в одни и те же минуты: getMe из tradein-tgbot
на Selectel — 9 успешных из 12, три ConnectTimeout; TCP-443 до адреса,
резолвящегося на Selectel (149.154.167.220) — 5 из 6; TCP-443 до адреса,
резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота
508 строк network error, за 30 дней 92 обрыва итерации poll loop. Значит:
путь до Telegram с Selectel лоссовый, с Beget чистый — Alertmanager (живёт
на Beget) шлёт в тот же чат без проблем, а бот поддержки на Selectel часть
отправок теряет.

Добавлен ops/metrics/tg-relay — stdlib-only HTTP-сервис (тот же принцип,
что у alert-ack: без зависимостей, поднимается даже когда всё остальное
сломано), проксирует Bot API целиком (метод, путь, тело — sendMessage,
copyMessage, getUpdates) на api.telegram.org. Токен из пути не логируется:
log_request переопределён полностью, путь редактируется до записи в лог.
Аутентификация — общий секрет в X-Relay-Secret, по образцу
X-Internal-Auth-Secret из этого же стека.

Клиент (tgbot/client.py) при транспортном отказе похода на ретранслятор
делает одну попытку напрямую к api.telegram.org — хуже прямого пути быть
не должно ни при каких условиях. Пустой TELEGRAM_RELAY_BASE_URL — прежнее
поведение без изменений, это и есть механизм отката.

Refs #3471
2026-09-12 14:16:05 +03:00
bot-backend
ac5b044f7e fix(scrapers): ожидаемые исходы сбора (бан, пустой пул, капча) больше не error
Some checks failed
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 18s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 6m17s
Скрапер один давал 3006 error-строк в сутки из ~3700 по всему Trade-In —
ленту перестали читать, и настоящая поломка терялась в ней.

Принцип: ожидаемый исход сбора (площадка забанила, пул прокси пуст,
капча/недогруз, серия блоков перевалила порог circuit breaker) — это
состояние работы против недружелюбного источника, а не инцидент. В error
остаётся только неожиданное: изменившаяся вёрстка/схема (Cian markup
change), просроченный токен ротации прокси (ASocks 401), неразобранное
исключение.

Переведено error -> warning в 9 файлах, 12 мест: "СТОП — пул прокси пуст"
(avito/domclick/cian_history/yandex_newbuilding_sweep x2), "пул прокси
исчерпан" (cian_session, cian_price_history, yandex_address_backfill),
FAIL-CLOSED без здорового узла для source (proxy_egress), ABORT по счётчику
подтверждённых блоков площадки (avito, domclick, yandex_detail_backfill).

Оставлено error намеренно: cookie-алерты Cian/DomClick (#2658, #2674) —
они рассчитаны именно на LoggingIntegration(event_level=ERROR) в
scheduler_main.py и без него молчат по 37 дней; ABORT по смешанным/soft
причинам без единого подтверждённого блока площадки (#3272, #2674/#3196) —
это может быть наш баг, а не бан, сигнал сознательно не приглушали.

GlitchTip: сентри-интеграция скрапера уже настроена как
LoggingIntegration(level=INFO, event_level=ERROR) в scheduler_main.py —
отдельной правки sentry_scrub.py не требуется, понижение уровня logger
само убирает эти записи из GlitchTip.

Итоговая FINISHED-строка со счётчиками (attempted/enriched/blocked/failed)
уже существует в каждом detail_backfill — новую не добавлял.

Refs #3471
2026-09-12 14:15:59 +03:00
bot-backend
5e80b56bdc fix(tg): out-строки писались с support_chat_id=NULL — вечный wildcard-матч
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m17s
Deep review PR #3479 нашёл дефект в предыдущем фиксе (#3471 пункт 3): новые
direction='out' строки стали видимы резолверам (find_chat_by_topic_message,
find_thread_by_topic_message), но писались без support_chat_id. Резолверы
матчат support_chat_id IS NULL как лениентный wildcard "любой текущий чат"
(легаси-строки до 187/188) — то есть КАЖДАЯ out-строка становилась таким
wildcard. При ротации support-группы новый message_id мог бы случайно
совпасть со старой out-строкой: TG-путь увёл бы ответ ЧУЖОМУ клиенту через
copyMessage, веб-путь записал бы ответ в чужой тред. Ровно от этого
защищали миграции 187/188 (review M1).

- bridge.py: TG- и веб-ветка `_handle_group_reply` теперь передают
  support_chat_id=settings.telegram_support_chat_id в record_message /
  record_web_out_message (симметрично уже существующей in-ветке).
- web_support_storage.record_outbound: добавлен параметр support_chat_id,
  пишется в INSERT (колонка уже существовала, DDL не нужен).
- Тест test_group_reply_to_own_previous_tg_reply_resolves_target_chat сидел
  предыдущую out-строку с уже заполненным support_chat_id вручную, хотя код
  писал NULL — маскировал дефект. Добавлены прямые проверки на записанное
  support_chat_id (TG и веб), обе падают на прежней реализации (проверено
  локальным откатом изменения — 2 failed, restore — 41 passed).
- Комментарий про "апдейт частично применён в Telegram" в except-ветке
  веб-ответа был неверен для этого случая (на веб-пути ничего не уходит в
  Telegram до сбоя БД) — переписан на настоящую причину: сбой БД не
  переигрывается по общей политике process_update, а не из-за частичной
  доставки.

Refs #3471
2026-09-12 14:15:57 +03:00
c53eaf7079 Merge pull request 'Маршрут и секрет для резервного приёмника алертов GlitchTip' (#3484) from chore/3471-glitchtip-fallback-wiring into main
Some checks failed
Deploy / deploy-status (push) Successful in 1s
Deploy Infra Host / sync-infra-host (push) Successful in 10s
Deploy Metrics / server (push) Successful in 26s
Deploy / changes (push) Successful in 11s
Deploy Metrics / agent-apps (push) Successful in 37s
Deploy Metrics / agent-infra (push) Successful in 30s
Deploy / build-backend (push) Successful in 52s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 57s
Deploy / build-frontend (push) Successful in 56s
Deploy / deploy (push) Successful in 1m17s
Deploy / perimeter-smoke (push) Has been cancelled
2026-09-12 11:12:28 +00:00
bot-backend
9eb42607b9 feat(glitchtip): фоновая ретрай-доставка алерта в Telegram при отказе синхронной попытки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 8m54s
GlitchTip не ретраит вебхуки (#3157) — is_sent проставляется безусловно сразу
после HTTP-ответа приёмника. При отказе Telegram синхронная попытка отвечала
502 и текст алерта пропадал безвозвратно (TRADE-IN-3F7, 28.08.2026; сеть до
Telegram с хоста теряет ~каждый четвёртый запрос — замер 12.09).

502 при отказе Telegram ОСТАВЛЕН как есть — он задуман осознанно (#3456) как
честный сигнал отправителю. Меняется судьба самого текста: перед возвратом 502
доставка ставится в фон через starlette.background.BackgroundTask на самом
JSONResponse (app.tasks.glitchtip_alert_retry.retry_forward_alert), а не через
FastAPI BackgroundTasks-зависимость — та привязывает задачи только к ответу,
который вернул сам хендлер, а `raise HTTPException` строит отдельный ответ в
exception-мидлваре, и такая задача не выполнилась бы вовсе (воспроизведено
тестом при первой попытке реализации).

Celery в проекте нет: ни app/celery_app.py, ни зависимости celery в
backend/pyproject.toml не существует — бутстрап полноценной очереди с воркером
вне границ этой задачи (новый контейнер/брокер). Фон использует штатную
"воркерную" ретрай-политику TelegramClient.send_message (5 попыток, backoff до
30s) плюс свой внешний потолок в 3 попытки, чтобы недоставляемый алерт не
крутился вечно — при исчерпании сдаётся с ERROR-логом текста. Переиспользует
существующее форматирование (_build_message) и общий клиент приложения, без
дублирования и новых переменных окружения.

Refs #3471, #3157
2026-09-12 14:11:01 +03:00
bot-backend
e7f127bc6e chore(ops): маршрут и секрет для резервного приёмника алертов GlitchTip
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 18s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Обвязка к #3482, который добавил сам эндпоинт в alert-ack, но не мог тронуть
caddy и workflow: файлы Caddy в тот момент правил параллельный PR #3478.

Три вещи: маршрут /glitchtip* на metrics.gendsgn.ru, проброс нового секрета
ALERT_ACK_GLITCHTIP_SECRET в окружение деплоя, и предупреждение шага, если
секрет пуст. Последнее не косметика: при пустом секрете эндпоинт отвечает 503
на всё, и без предупреждения резервный канал молча не поднялся бы.

Секрет намеренно отдельный от продуктового TRADEIN_INTERNAL_AUTH_SECRET —
это другой хост и другой домен безопасности.

Refs #3471, #3482
2026-09-12 14:09:37 +03:00
01960b03be Merge pull request 'Резервный канал для алертов GlitchTip: приём на alert-ack, другой хост и другой провайдер' (#3482) from feat/3471-glitchtip-fallback-alert-ack into main
All checks were successful
Deploy Metrics / server (push) Successful in 32s
Deploy Metrics / agent-infra (push) Successful in 26s
Deploy Metrics / agent-apps (push) Successful in 35s
2026-09-12 11:08:25 +00:00
1254294ac3 Merge pull request 'Alertmanager объявлен единственным путём доставки: встроенный алертинг Grafana выключен явно' (#3481) from chore/3158-grafana-alerting-off into main
Some checks are pending
Deploy Metrics / server (push) Waiting to run
Deploy Metrics / agent-apps (push) Blocked by required conditions
Deploy Metrics / agent-infra (push) Blocked by required conditions
2026-09-12 11:08:09 +00:00
2d87f70711 Merge pull request 'Access-логи Caddy доезжают в Loki: статусы и латентность прокси наконец видны' (#3478) from feat/3471-caddy-access-logs into main
Some checks failed
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy Infra Host / sync-infra-host (push) Successful in 7s
Deploy / changes (push) Successful in 12s
Deploy / build-backend (push) Successful in 51s
Deploy / build-worker (push) Successful in 47s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 51s
Deploy / deploy (push) Has been cancelled
2026-09-12 11:05:45 +00:00
5d4d17a5e1 Merge pull request 'Тревоги уровня приложения, cAdvisor и пропавшие фоновые контейнеры; critical переживает подавление' (#3477) from feat/3471-app-alerts-coverage into main
Some checks failed
Deploy Metrics / agent-apps (push) Blocked by required conditions
Deploy Metrics / agent-infra (push) Blocked by required conditions
Deploy Metrics / server (push) Has been cancelled
2026-09-12 11:05:37 +00:00
9298db0803 Merge pull request 'Панель классов ответов больше не стекируется — красная линия и есть число 5xx' (#3474) from fix/3471-dashboard-5xx-stacking into main
Some checks are pending
Deploy Metrics / server (push) Waiting to run
Deploy Metrics / agent-apps (push) Blocked by required conditions
Deploy Metrics / agent-infra (push) Blocked by required conditions
2026-09-12 11:05:33 +00:00
c3840019c5 Merge pull request 'Москва в реестре городов лендинга и кабинета' (#3472) from feat/msk-public-city-registry into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m3s
Deploy Trade-In / test (push) Successful in 5m21s
Deploy Trade-In / build-backend (push) Successful in 53s
Deploy Trade-In / deploy (push) Successful in 1m52s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m50s
2026-09-12 11:04:17 +00:00
bot-backend
165c4a5edd fix(obs): убрать шум выключенных платежей и трейсы health-проб proxy_pool
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m59s
Два источника шума в error-ленте и логах:

1. GlitchTip группа TRADE-IN-3GG: 167 событий за 29.08-12.09 — 503
   "payments are disabled" из payments.py._require_enabled, которые бьёт
   внутренний IP смоук-проверки (кнопки оплаты во фронте нет). sentry_sdk
   StarletteIntegration репортит любой HTTPException с кодом из 5xx как
   error-событие, даже когда FastAPI штатно обработал исключение и вернул
   корректный ответ. Добавлен before_send-фильтр
   drop_payments_disabled_event (app/observability/sentry_scrub.py),
   матчащий по (status_code=503, detail="payments are disabled") через
   hint["exc_info"] — не по коду 503 в целом, чтобы не проглотить другие
   503. Подключён во всех трёх точках инициализации sentry_sdk.init
   (app/main.py — единственный реальный источник события,
   scheduler_main.py и tgbot_main.py — belt-and-suspenders для
   единообразия, по образцу scrub_payment_request_body). Само поведение
   ручки не меняется — 503 остаётся, фильтруется только репортинг в
   трекер.

2. proxy_pool._probe_proxy: httpx.ProxyError (407 от прокси-провайдера)
   не попадал ни под TimeoutException, ни под ConnectError и падал в
   generic except Exception с exc_info=True — 184 строки полного
   traceback в сутки на штатный провал health-пробы, хотя итоговая
   сводка checked/ok/failed и так его учитывает. Добавлена отдельная
   ветка except httpx.ProxyError с логом в одну строку (узел + причина
   текстом исключения, без трейса). Логика самой пробы, аренды узлов и
   правил пула не изменена.

Тесты: tests/test_sentry_scrub.py (drop_payments_disabled_event — дропает
целевой 503, пропускает прочие ошибки и прочие 503/detail-комбинации),
tests/services/test_proxy_pool.py (ProxyError логируется одной строкой
без exc_info, счётчики healthcheck не ломаются).

Refs #3471
2026-09-12 14:03:39 +03:00
bot-backend
423842ae36 feat(ops): резервный получатель GlitchTip-алертов в alert-ack
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Все три alert-правила GlitchTip (backend, frontend, Trade-In) сейчас шлют
единственный вебхук в продуктовый бэкенд на Selectel — тот самый хост, за
которым они следят. Если там упал backend или Caddy, ошибки приложения
задерживаются или пропадают именно тогда, когда нужнее всего.

Добавлен POST /glitchtip в alert-ack (живёт на инфраструктурном хосте Beget,
не зависит от здоровья продукта): второй получатель того же Slack-совместимого
payload, аутентификация секретом в заголовке X-GlitchTip-Secret или query
?secret= (тот же подход, что у tradein-mvp/backend/app/api/v1/glitchtip.py).
Сообщение уходит в существующую тему клиентских инцидентов с явной пометкой
«резервный канал». Секрет свой (ALERT_ACK_GLITCHTIP_SECRET), не переиспользует
продуктовый TRADEIN_INTERNAL_AUTH_SECRET.

Refs #3471
2026-09-12 14:01:30 +03:00
bot-backend
93451fae08 chore(ops): выключить встроенный Grafana Alerting, единственный путь — Alertmanager
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Проверка живого API Grafana 12.09.2026: правил алертинга ноль, контакт-поинт
единственный и стоковый — grafana-default-email на example@email.com,
GF_SMTP_* не заданы. Интерфейс выглядит настроенным, кнопка "New alert rule"
работает, а результат молча уходит в никуда — ровно тот случай, из-за
которого никто не проверяет второй, настоящий путь доставки.

Дублирующий движок на том же датасорсе Prometheus надёжности не добавляет
(общая точка отказа), а поддержку удваивает. Решение: Alertmanager —
единственный путь доставки тревог, Grafana только рисует.

GF_UNIFIED_ALERTING_ENABLED: "false" в docker-compose.metrics.yml (секция
grafana) — единственная официальная секция [unified_alerting] в Grafana 11.x,
легаси-[alerting] удалён из Grafana ещё в 9.0 (сверено с grafana.com/docs/
grafana/v11.5/setup-grafana/configure-grafana/#unified_alerting). Разом
убирает Alerting из UI и глушит движок правил, так что искать и вычищать
стоковый контакт-поинт отдельно не требуется.

ops/metrics/grafana/provisioning/alerting/README.md — явная отметка для
следующего человека: провижинить contact points/rules в эту папку не нужно,
Grafana её при выключенном unified alerting не читает.

Closes #3158, refs #3471
2026-09-12 14:01:04 +03:00
bot-backend
99f123e646 fix(tg): ответ оператора на веб-чат не теряется молча при сбое БД
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m56s
Для веб-треда запись в web_support_messages(direction='out') И ЕСТЬ доставка
клиенту (веб-фронт читает её polling'ом). process_update на SQLAlchemyError
безусловно делал rollback() и всё равно сдвигал offset — Telegram апдейт
больше не отдавал, ответ оператора пропадал навсегда, а сам оператор был
уверен, что ответил. Воспроизведено на проде 31.08.2026 (клиент kopylov).

- `_handle_group_reply`: сбой БД на `record_web_out_message` теперь ловится
  локально — rollback → уведомление оператору реплаем в топик, что ответ НЕ
  доставлен и его нужно повторить; offset всё равно сдвигается (апдейт уже
  частично применён в Telegram, переигрывать нельзя).
- `_notify_topic` возвращает bool: если само уведомление тоже упало (Telegram
  недоступен), пишем `logger.error` с thread_id/message_id (без текста
  переписки — ПДн в лог не идёт), чтобы это не осталось полностью немым.
- Второй дефект того же узла: `direction='out'`-строки никогда не сохраняли
  topic_message_id, из-за чего реплай оператора на СВОЙ предыдущий ответ не
  резолвился (маршрут держался только на зеркале клиента). Теперь TG- и
  веб-путь сохраняют id ответа оператора в топике, `find_chat_by_topic_message`
  / `find_thread_by_topic_message` больше не фильтруют по direction. Колонка и
  partial unique индекс уже существовали (186/187) — миграция не потребовалась.

Refs #3471
2026-09-12 13:59:52 +03:00
bot-backend
25c938a833 feat(ops): access-логи Caddy на stdout для боевых доменов — Alloy теперь видит их в Loki
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
За 3 часа в gendesign-caddy-1 не было ни одной строки http.log.access — только
ACME/TLS/warn от reverse_proxy. Статусы, латентность и RPS фронтового прокси
были не видны в Loki, 5xx приходилось искать в логе uvicorn.

Боевой конфиг собирается из caddy/sites/apps.caddy (импортируется корневым
Caddyfile через `import caddy/sites/{$CADDY_SITES:*}.caddy`, CADDY_SITES=apps
на Selectel/Poincare) — правка внесена туда, не в плоский Caddyfile в корне
(93 строки в git — это только заготовка, реальный конфиг на проде собирается
из caddy/sites/* + сниппетов, ~20КБ через admin API). caddy/sites/infra.caddy
(Beget: obsidian/errors/git.gendsgn.ru) не трогал — не боевой трафик, вне
скоупа issue.

Для gendsgn.ru и meraocenka.ru добавлен второй логгер (access_stdout, JSON)
рядом с существующим файловым — Alloy на этом хосте уже собирает stdout
контейнеров через journald (loki.source.journal в
ops/metrics/alloy/alloy-apps.alloy), второй bind-монт не нужен.

Объём: по измерению на проде 12.09.2026 (docker exec, wc -l + первый/последний
ts в текущих файловых логах) — gendsgn.ru ~4.5k запросов/сутки, meraocenka.ru
~4.2k запросов/сутки. После исключения шумных путей (см. ниже) новая копия
на stdout — это дополнительно ~6-7 МБ/сутки в Loki, то есть около +15-20% к
текущим ~39 МБ/сутки при ретенции 30 дней (после сжатия Loki фактический
прирост диска меньше).

Шумные пути исключены через log_skip: /health на gendsgn.ru — 32% строк
файлового лога в измеренном сегменте (аптайм-монитор раз в минуту, без
диагностической ценности), статика Next (_next/static, trade-in/_next/static)
на обоих доменах — 5.1% строк на meraocenka.ru. Важный нюанс: log_skip в
Caddy — общий флаг на запрос для ВСЕХ логгеров сайта, скипать выборочно
только stdout-копию нельзя, поэтому эти пути пропадают и из существующих
файловых логов тоже (gendsgn.ru.log, meraocenka.ru.log) — осознанный побочный
эффект, а не только экономия трафика в Loki.

Секреты в query-параметрах (?secret=, ?token= и т.п. — инцидент #3154) второй
раз не чистим: уже работающий loki.process.scrub_credentials в
ops/metrics/alloy/alloy-apps.alloy (#3354, тот же список имён, что в
app/core/log_scrub.py) стоит на пути ЛЮБОГО journal-лога и вырежет их до
записи в Loki. Alloy-конфиг не менял — существующий пайплайн уже покрывает
новый источник.

Осталось за скобками (не входит в этот PR): caddy/sites/infra.caddy на Beget
логирует access тем же способом (файл, не stdout) — если нужна наблюдаемость
git.gendsgn.ru/errors.gendsgn.ru/metrics.gendsgn.ru, это отдельная задача с
тем же паттерном.

Refs #3471
2026-09-12 13:58:48 +03:00
bot-backend
655652ae1c feat(ops): алерты уровня приложения и три слепые зоны мониторинга
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
- AppHighErrorRate / AppHighLatencyP95 (job="app", severity=critical,
  host="apps") — доля 5xx и p95 задержки по http_requests_total /
  http_request_duration_seconds_bucket теперь ловятся Prometheus'ом, а не
  только постфактум в GlitchTip. Пара critical+apps обязательна для
  маршрута telegram-clients в alertmanager.yml.tmpl.
- Inhibit по HostAgentDown больше не гасит critical того же хоста —
  target_matchers сужен до severity="warning" (падение node-exporter
  раньше молча забирало с собой PostgresLongTransactionCritical и
  critical-алерты cAdvisor).
- cAdvisor: keep-фильтр в alloy-infra.alloy резал у него `up` наравне с
  container_*-мусором — job "cadvisor" не публиковал свою же серию `up`.
  Пропущены up/scrape_samples_scraped, добавлен CadvisorDown.
- TradeInBackgroundContainerMissing по absent(container_last_seen) на
  tradein-tgbot/tradein-scraper — эти контейнеры не HTTP-сервисы и в
  up{} не участвуют вовсе; крэш без рестарта раньше не алертился.

Не закрыто: живой, но зависший процесс tgbot/scraper (container_last_seen
не про внутренний прогресс, а про то, что Docker видит контейнер running).

Refs #3471
2026-09-12 13:57:54 +03:00
2e928c715b Гейт #3448: закрыть зелёные мутации, добавить признак непустоты, запускать на ci-tradein.yml
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m42s
CI / openapi-codegen-check (pull_request) Successful in 2m37s
CI / backend-tests (pull_request) Successful in 19m21s
Мутационный прогон нашёл пять зелёных мутаций — то есть мест, где логику можно
сломать, а гейт этого не заметит. Закрыты фикстурами, каждая краснеет ровно на
своей мутации:

* CADDY_RE → `^(Caddyfile|caddy)`: тогда `caddy-extra/**` и `Caddyfile.bak`
  дают caddy_only=true — тихий пропуск полного деплоя, против которого весь PR;
* снятие проверки «файлов больше нуля»: пустой дифф формально удовлетворяет
  «ни один файл не лежит вне caddy» и отключает сборку;
* выпадение `data/sql/**` из backend: миграции едут в backend-образе;
* подмена базы на `HEAD^..HEAD`: на ОДНОМ мерж-коммите даёт верный ответ и
  выглядит рабочей, а на push'е из нескольких коммитов теряет первый — фикстура
  «бэкенд-коммит + caddy-коммит» это ловит;
* потеря `core.quotePath=false`: кириллический путь под backend/ выпадает из
  классификации.

Плюс прод-сторож из deploy-caddy: его кусок (от PROD_HEAD до `git reset --hard`)
извлекается из ssh-скрипта и ИСПОЛНЯЕТСЯ на временном репозитории, где прод-дерево
отстаёт от origin/main — отдельно законный случай (отстал только конфиг прокси)
и отказной (отстал бэкенд). Проверяется и порядок: сторож обязан стоять ДО
`git reset`. Команда ищется регуляркой по началу строки, а не подстрокой:
`git reset --hard` упоминается выше в комментариях, и поиск по тексту находил
объяснение вместо кода.

Признак непустоты у проверки исключающих `!`-шаблонов: раньше она бы прошла при
нулевом охвате (переименуют действие, заведут .yaml) — теперь отдельно
утверждается, что хотя бы один шаг paths-filter найден, как это сделано в ci.yml
для shell-гейта. Маска расширена до *.y*ml, параметризация — по найденным шагам.

ci.yml: в фильтр `backend` добавлен `.forgejo/workflows/ci-tradein.yml` — там
тоже живёт paths-filter, и без этой строки правка с `!`-шаблоном не запустила бы
backend-tests, то есть гейт не побежал бы ровно на той правке, от которой стережёт.

Докстринг фикстуры с мержем переписан: он утверждал, что «дифф последнего
коммита» на мерж-коммите даёт пустой список (это верно для `git show`, а не для
`git diff HEAD^ HEAD`) — то есть обещал защиту, которой у этой фикстуры нет.
Теперь там сказано, что подмену базы стережёт отдельная проверка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:57:11 +05:00
9a8aaa2d2d deploy-caddy: сторож отставания прода, общий лок и квотирование путей (#3448)
Оживший быстрый путь снимает гард свежести :latest. Пока caddy_only был мёртв,
любой push шёл полным деплоем, и гард #2950 прикрывал прод по умолчанию. Теперь
при caddy_only=true джоба `deploy` пропускается целиком — вместе с гардом.

Сценарий отказа. Push A правит бэкенд, билды ~6 мин, `deploy` в очереди. Через
2 мин push B правит только caddy/. На Forgejo 10.0.3 ещё не стартовавшая
`deploy` предыдущего прогона отменяется ДАЖЕ при cancel-in-progress: false
(наблюдение 21.08.2026 10:35:13, шапка scripts/check-latest-image-revision.sh;
workflow-level concurrency там не исполняется, см. deploy.yml). Дифф A..B — один
caddy-файл, быстрый путь включается, `compose pull` + `up -d` не делает никто:
прод крутит старый образ, голова main зелёная, сигнала нет.

Сторож в ssh-скрипте deploy-caddy: если прод отстаёт от origin/main не только
по Caddyfile/caddy/**, быстрый путь запрещён, шаг падает и называет файлы.
Стоит ДО `git reset --hard` намеренно — при отказе прод-HEAD остаётся честным
для следующего прогона. У Trade-In для того же заведён отдельный маркер
(/opt/gendesign/.tradein-deployed-sha, deploy-tradein.yml), у ПТИЦЫ маркера нет,
и `git reset` делает прод-HEAD его эквивалентом.

ЧЕГО СТОРОЖ НЕ ЛОВИТ: `deploy`, упавшую ПОСЛЕ `git reset --hard` (например на
миграции). Тогда прод-HEAD уже равен новому коммиту, а контейнеры старые —
это остаётся за настоящим маркером «что задеплоено».

Тот же лок, что и у полного деплоя. deploy-caddy делает `git reset --hard` в
/opt/gendesign, то есть правит прод-дерево — ровно то, что job `deploy`
сериализует через flock /var/lock/gendesign-docker-deploy.lock. Пока путь был
мёртв, сталкиваться было нечему; теперь это первая джоба, трогающая прод-дерево
в обход сериализации.

Квотирование путей. `git diff --name-only` и `git ls-files` при core.quotePath
(умолчание true) отдают не-ASCII пути закавыченными с \NNN-экранированием —
`^backend/` такую строку не матчит, и файл backend/<кириллица>.py дал бы
backend=false. Старый paths-filter брал `--name-status -z`, где квотирования
нет: это единственное место, где переход на свой diff менял поведение. В дереве
такие пути уже живут (docs/Бизнес-план…). Добавлен `-c core.quotePath=false` в
обе команды и в сторож выше.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:56:24 +05:00
bot-backend
6febb36afd fix(ops): деплой метрик перечитывает конфиг Prometheus, а не только кладёт его на диск
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m14s
CI / backend-tests (pull_request) Successful in 19m15s
lastConfigTime у gendesign-prometheus совпадал со startTime контейнера 16
суток: docker compose up -d не пересоздаёт контейнер из-за изменения
содержимого бинд-маунта (сравнивается только описание сервиса), а
--web.enable-lifecycle был включён, но /-/reload никто не вызывал. Любая
правка ops/metrics/prometheus/** доезжала до диска и молча не вступала в
силу до случайного рестарта, при зелёном деплое.

Добавлен шаг по образцу уже работающей проверки Caddyfile в этом же
workflow: promtool check config + promtool check rules внутри контейнера,
reload только при успешной проверке, приёмка через сравнение
lastConfigTime до/после (обновляется на каждый успешный reload, поэтому
надёжно ловит и несостоявшийся вызов). Провал promtool теперь роняет шаг
и не трогает работающий Prometheus.

Alertmanager уже чинился отдельно (--force-recreate, #3078/#3136) — reload
для него намеренно не помогает из-за переиспользуемого инода, это не
regressed. Loki (/etc/loki/loki-config.yml), Grafana (provisioning) и Alloy
(config.alloy) в том же деплое лежат на дисковых бинд-маунтах без reload —
чинить их этим PR не стал, см. summary задачи.

Refs #3467, #3471
2026-09-12 13:55:53 +03:00
1eee4b955d Merge pull request 'ДКП-коридор по Москве не строился: имя улицы не извлекалось из московского формата адреса' (#3473) from fix/msk-street-name-suffix into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / test (push) Successful in 5m8s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
2026-09-12 10:53:45 +00:00
42daf8404f Merge pull request 'feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %; плитку «уверенность низкая» сменил замер 12.09' (#3468) from feat/landing-showcase-band into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-09-12 10:51:49 +00:00
bot-backend
412d78f357 fix(ops): панель классов ответов больше не стекируется — красная линия и есть число 5xx
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
11.09 панель «Запросы по классам ответов» дала ложную тревогу: при stacking=normal
верхняя, красная линия рисуется на высоте суммы всех классов, и её положение
читается как объём пятисоток. Фактически за то окно их было шесть.

Стек здесь ничего не даёт: суммарный трафик уже показан отдельной панелью
«Запросов в минуту», а от этой нужна форма каждого класса по отдельности.
Заливка снижена, линия утолщена — без стека 25% заливки перекрывают друг друга.

Описание панели теперь прямо говорит, что линии независимы.

Refs #3471
2026-09-12 13:49:45 +03:00
00e0bddfb4 Merge pull request 'Московский city_hint больше не уходит молча в регион 66' (#3470) from feat/msk-suggest-region-inference into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m47s
Deploy Trade-In / build-backend (push) Successful in 1m14s
Deploy Trade-In / deploy (push) Has been cancelled
2026-09-12 10:44:39 +00:00
db47fa0ecd fix(mera/лендинг): утверждение про полосу проверяет само себя по показанным строкам
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m48s
CI Trade-In / backend-tests (pull_request) Successful in 6m0s
Дыра в выкате, найденная ревьюером до мержа. Подпись про полосу собиралась из
констант `BAND_MIN_PCT`/`BAND_MAX_PCT` в коде фронта, а строки витрины и
`rejection_rule` приезжают из БД, от ПОСЛЕДНЕГО прогона задачи
`landing_showcase_deals`. Задачи нет в расписании — её запускают руками.

Значит в окне «фронт выкачен, витрина не пересчитана» страница утверждала бы
«показаны сделки с расхождением от −5 % до +20 %», а под утверждением лежали
бы прежние двадцать строк: по замеру на проде 12 из 20 вне полосы, худшая
+75,7 %. Утверждение и его опровержение в одном экране — хуже, чем было до
правки.

Чинится конструкцией, а не запуском задачи: `allWithinBand(deals)` в
`deal-view.ts` спрашивает САМИ показанные строки теми же границами, что стоят
в тексте.

* Все показанные строки в полосе — печатаем прежнюю формулировку.
* Хоть одна вне — про полосу НЕ утверждаем. В таблице: «Полосу расхождения от
  -5 % до +20 % эта подпись не обещает: среди показанных строк есть
  расхождения вне неё, то есть витрину собрал прогон с другим правилом — тем,
  что напечатано выше». Правило того прогона и так приезжает в
  `rejection_rule` из ТОГО ЖЕ прогона, что и строки, поэтому подпись с ними
  согласована по построению. В ленте остаётся только то, что посчитано по
  строкам: медиана и худшая.
* Медиана по ВСЕЙ сверке (15,3 %, 325 сделок) печатается в обеих ветках — она
  и удерживает страницу честной независимо от того, пересчитана витрина.

Тесты по значению в обе стороны: набор с одной строкой вне полосы (+75,71 % —
реальная строка прода) → утверждения про полосу нет; все в полосе → есть.
Фальсификация: `allWithinBand` обезврежен руками (всегда true) — краснеют оба
новых теста, и красный текст показывает ровно тот дефект:
«Это отобранная полоса расхождения от -5 % до +20 % … худшая 75,7 %».
Проверка возвращена.

Проверка заодно поймала мои же фикстуры ленты: −11,5 % ниже нижней границы
полосы (−5 %), то есть «маленькое отклонение» ещё не значит «в полосе».
Значения заменены на внутриполосные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:44:24 +05:00
bot-backend
d78b1f8881 fix(tradein): ДКП-коридор по Москве не строился — имя улицы не извлекалось
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m5s
`extract_street_name` возвращал None для любого московского адреса, потому
что парсер ждёт тип улицы ПЕРЕД названием («ул. Малышева»), а в Москве он
стоит после: «Тверская улица, 6». Keyword-регекс требует пробел сразу за
типом, там запятая — совпадения нет вовсе; дальше fallback брал первый
токен с большой буквы, получал «Москва» из стоп-списка и отдавал None.

Следствие на проде (замер 12.09): оценка по московскому адресу отвечает
200 с 25 аналогами, но `dkp_corridor` в ответе — null, при 212 937
московских ДКП в базе. Коридор сделок по Москве не строился ни разу.

Добавлен второй проход: ищем тип улицы без требования пробела и берём
1-3 слова ДО него в пределах той же запятой-секции. Прежний путь не
тронут — «ул. X» и реверс-формат Nominatim разбираются как раньше;
непустые результаты не меняются, новый проход даёт значение только там,
где раньше был None. Списки типов улиц вынесены в общую константу, чтобы
два регекса не разъехались при добавлении нового типа.

Нумерованные проезды («Проектируемый проезд № 4062») намеренно остаются
None: имя «Проектируемый» собрало бы коридор по сотням разных проездов.

## Регион-скоуп двух ручек

Непустое имя улицы включает `/street-deals` и `/sales-vs-listings`, где
раньше для Москвы был ранний выход. Обе скоупятся только по
`_resolve_target_city` — словарю городов Свердловской области, — поэтому
для Москвы фильтр города пуст, и остаётся один ILIKE по улице.

Замер на проде: улица «Ясная» — 168 сделок в регионе 66 и 80 в 77,
«Советская» — 1202 и 17. Без фильтра региона московский запрос смешал бы
екатеринбургские сделки в медиану, то есть фикс парсера сам по себе
открыл бы дыру. Поэтому в обе ручки добавлен обязательный фильтр по
`region_code`; регион выводится из адреса через реестр регионов точным
сравнением сегмента, а не подстрокой — иначе екатеринбургская
«Московская улица» уехала бы в регион 77.

В `deals` регион заполнен у всех строк (66 → 108 623, 77 → 212 937,
NULL нет), так что фильтр ничего не отрезает у существующих запросов.

У `/sales-vs-listings` табличная функция параметра региона не знает, её
миграция в этот фикс не входит. Фильтр применён снаружи, соединением с
`deals` по идентификатору сделки: сторона объявлений остаётся без
регион-скоупа. Это осознанный компромисс, он описан в коде; полный фикс
— отдельная миграция с параметром региона внутри функции.

Тесты: 535 passed во всех файлах, затрагивающих коридор и уличную
статистику (+13 новых), ruff чистый.
2026-09-12 13:43:56 +03:00
bot-backend
c6711d05c4 feat(mera-public): Москва в реестре городов лендинга и кабинета
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m20s
CI Trade-In / backend-tests (pull_request) Successful in 6m2s
Реестр городов один на публичную форму и кабинет, и до сих пор он знал
только Свердловскую область. Московский адрес нельзя было выбрать ни там,
ни там, хотя бэкенд Москву поддерживает: реестр регионов знает 77, проба
покрытия знает московские центроиды, оценка отрабатывает.

Москва подана НЕ как ещё один «частично покрытый город области», а
отдельной строкой: сбор по ней есть, а замера полноты покрытия нет, и
приписывать ей формулировки области было бы неправдой. Для этого у
`OblastCity` появилось поле `region`, а `SECONDARY_CITIES` теперь строится
из `OBLAST_66_CITIES` — иначе Москва попала бы в перечисление городов
области. Бэкенду поле не отправляется: это различение нужно только фронту.

В `landing-facts.ts` строки Москвы сознательно нет — там лежат замеры
покрытия по городам, а по Москве замера не делали. Придумывать цифру
нельзя, поэтому паритет-тест копи сверяет замеры с `OBLAST_66_CITIES`.

Тексты про географию переписаны в четырёх местах: плашка покрытия на
главной, карточка бесплатной пробы, ответ FAQ про регионы и сообщение
«адрес вне покрытия». Везде одна и та же честная формулировка: по области
— полное и частичное покрытие, по Москве — считаем, но полноту не мерили.
Юридический адрес в подвале не трогали, там «Свердловская область» — это
адрес компании, а не география сервиса.

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

Фронт: 218 passed, tsc и eslint чистые. Бэкенд: 34 passed в затронутом файле.
2026-09-12 13:40:21 +03:00
bot-backend
ef82a707fc fix(mera): московский city_hint больше не уходит молча в регион 66
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m31s
Публичный `/suggest` и кабинетный `/api/v1/geocode/suggest` всегда звали
геокодер с `region_code=66`: публичная ручка регион не передавала вовсе,
а у кабинетной он был обязательным параметром со значением по умолчанию.
`city_hint="Москва"` на это не влиял — DaData и Nominatim получали
свердловский hard-констрейнт и молча возвращали ПУСТО. С сайта и из
кабинета московский адрес просто нельзя было ввести, хотя оценка,
проба покрытия и реестр регионов Москву уже поддерживают.

Добавлен `effective_region_code()`: явный `region_code` важнее вывода из
`city_hint`, вывод идёт через существующий реестр `app.services.regions`
(`REGIONS[77].cities` содержит «москва»), последний рубеж — прежний
`DEFAULT_REGION_CODE`. Отдельного списка городов не заводим: разъехаться
двум спискам — вопрос времени.

Поведение сегодняшних клиентов не меняется байт-в-байт: без `city_hint`
и с любым свердловским городом регион по-прежнему 66. Публичная схема
принимает `region_code` на будущее — если фронт когда-нибудь начнёт его
слать, он будет приоритетнее хинта; неизвестный регион как и раньше
отдаёт 422 из геокодера, а не 500.

Тесты: четыре инварианта на сам хелпер (нет хинта → 66; свердловский
город → 66; Москва → 77; явный 66 поверх Москвы → 66) и по одному на
каждую ручку — что вниз по потоку уезжает ожидаемый регион. Прежние
тесты region-скоупа геокодера не тронуты.

189 passed в связанных файлах, ruff чистый.
2026-09-12 13:35:41 +03:00
2467943200 feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %, плитку уверенности сменил замер 12.09
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
CI Trade-In / backend-tests (pull_request) Successful in 5m29s
Владелец просит на витрине только сделки, где прогноз разошёлся с ценой ДКП
в пределах от −5 % до +20 %. Фильтр живёт в продюсере (`select_rows`), поэтому
таблица сверок и бегущая строка берут ОДИН набор, а не два.

Чтобы страница от этого не начала врать:

* `REJECTION_RULE` переписан. Прежняя формулировка («величина отклонения на
  отбор и отбраковку не влияет — иначе витрина показывала бы лучший хвост»)
  после фильтра стала ложью ровно про то, чего опасалась, поэтому снята, а не
  смягчена. Новая называет полосу и говорит, что это отбор показательных
  строк, а не вся сверка. Границы в текст ПОДСТАВЛЯЮТСЯ из констант
  `BAND_MIN_ERR_PCT`/`BAND_MAX_ERR_PCT` — подпись не может разъехаться с
  фильтром, и это проверяется тестом.
* Фильтр стоит в `select_rows`, а не в `build_row`: строка вне полосы остаётся
  кандидатом и попадает в `eligible`. Отбраковав её раньше, мы получили бы
  «показано 20 из 20 годных» — счётчик, из которого отбор не виден вообще.
* Счётчики разъехались с подписью, и подпись поправлена: `eligible − written`
  больше не значит «столько не поместилось», в разницу входят отсеянные
  полосой. Под таблицей теперь «показано N строк из M собранных прогоном».
* «В пределах 20 % — N из N» из подписи снято: при потолке полосы +20 счёт
  всегда выходил бы N из N и читался бы как замер попадания. Неработающая
  проверка читается как работающая.
* Медиана по ВСЕЙ сверке (15,3 %, 325 сделок) в подписи осталась и теперь
  сторожится тестом: без неё разброс отобранной двадцатки читается как
  точность расчёта.
* Полоса названа и в подписи ленты — она висит над первым экраном, её числа
  читают раньше любых оговорок блока «Точность».
* Меньше лимита в полосе — показываем сколько есть, добора нет.

Плитка «400 из 400 расчётов с пометкой „уверенность низкая“» заменена на
свежий замер 12.09.2026 (engine=full, 290 сделок, медиана трёх пересборок с
солями 11/22/33): «52,7 % сделок — расхождение в пределах ±20 %». Запись
`confidenceLow` не удалена, а помечена снятой (прогон 29.08 на
кластеризованной выборке) — до решения владельца.

Оговорки новой величины называют три вещи, без которых она льстит: замер не
point-in-time, разброс пересборок 46,2–56,6 %, и что медианное расхождение
того же прогона (19,1 %) ВЫШЕ прежних 15,3 % от 31.08 — на странице два числа
разных дат, и молчать о том, что свежий прогон вышел хуже, нельзя.

`priceError` и `coverage` не тронуты. Сторож свежести теперь следит за ОБЕИМИ
датами замеров, а не только за 31.08.

Проверено: на проде из 20 сегодняшних строк витрины в полосу попадают 8
(40 %), что сходится с 35,5 % «доли в полосе» из бэктеста 12.09.
Фальсификация: снятие фильтра руками красит 3 теста, ключевой — по значению
([44, 43, 41] вместо [44] на реальных строках прода +75,7 / −27,9 / +9,9 %).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:29:10 +05:00
cfcdb9393c Merge pull request 'Тревоги печатали не ту величину, которую называли: «2.684e+11% от mem_limit» и «доля HOT 75.21%» при пороге 20%' (#3464) from fix/alert-value-is-not-the-ratio into main
All checks were successful
Deploy / changes (push) Successful in 12s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 22s
Deploy Metrics / agent-infra (push) Successful in 27s
Deploy Metrics / agent-apps (push) Successful in 29s
Deploy / build-worker (push) Successful in 45s
Deploy / build-backend (push) Successful in 47s
Deploy / deploy (push) Successful in 1m12s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m42s
2026-09-12 10:12:52 +00:00
03d9745b4f Отмена по бюджету больше не оставляет поток в сессии запроса — оценка не теряется на 500 (#3449)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m28s
Deploy Trade-In / test (push) Successful in 4m38s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 1m46s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-12 10:11:19 +00:00
dfc39355f9 Merge pull request 'Коридор сделок с малой выборкой честно помечен справочным' (#3462) from fix/3452-corridor-advisory-zone into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-12 10:11:14 +00:00
668ac40631 fix(tradein): подпись коридора говорит про выборку, а не про алгоритм
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m9s
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
Ревью #3462 поймало ложь в микрокопии: «оценку по ним не корректировали»
утверждает про АЛГОРИТМ то, чего код не гарантирует. Порог
estimate_corridor_clamp_min_n гейтит только две страховки — кламп headline и
radius-floor. Третий ценовой путь, гейт Tier C (#1795 шаг 3, estimator.py),
сравнивает якорь с потолком коридора БЕЗ порога вообще: коридор из пяти сделок
там способен уронить headline на треть (воспроизведено ревьюером: якорь Tier C
300 000 ₽/м², с коридором 200 000 против 300 500 без него). Плюс
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.

Формулировка переписана на утверждение о ДАННЫХ — оно истинно во всех
достижимых состояниях: «справочно: сделок мало (N) — коридор ориентировочный».

Ветка «объявлений рядом нет» (n_analogs = 0) больше не молчит: раньше там
возвращался null, и клиент не узнавал, что вся его цена стоит на трёх сделках.
Теперь — «оценка построена на этих сделках — их всего N».

Докстринги advisory_only в схеме и комментарий у лога тоже перестали обещать
«коридор в цену не пошёл»: поле значит ровно «страховки выключены».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:36:29 +05:00
33714e464e fix(alerts): в тексте тревоги печаталась не та величина, которую текст называет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m30s
CI / backend-tests (pull_request) Successful in 17m50s
Боевые сообщения в Telegram 12.09:
  «apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.»
  «apps / listings: доля HOT 75.21%.» — при пороге срабатывания «доля < 20%»

Причина общая и она про ФОРМУ выражения, а не про условие: в PromQL `A and B`
возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. В `$value` попадало A:

  - ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes` —
    в сообщение уходил лимит в байтах (2 684 354 560), отрендеренный как
    процент. Замер 12.09: настоящее потребление того контейнера — 1.2 % лимита.
  - PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` — апдейтов в
    секунду. Это опаснее: 0.7521 превращалось в «75.21%», попадало в
    правдоподобный диапазон и противоречило собственному порогу, но выглядело
    настоящим числом. Замер 12.09 по listings: rate(tup_upd[6h]) = 0.0411 →
    сообщение сказало бы «4.11%», настоящая доля HOT = 0.00%.

Условия срабатывания в обоих случаях были ВЕРНЫ — врал только текст, поэтому
дефект и прожил незамеченным.

Правка: отношение вынесено влево, а побочное условие — внутрь знаменателя
(`X / (Y > 0)`), где оно и фильтрует серии, и защищает от деления на ноль.
Проверено на живом Prometheus (только чтение): новое выражение памяти отдаёт
доли 0.35–0.71 (топ — gendesign-infra-postgres 70.8 %), новое выражение HOT —
доли 0.00–1.00. `promtool check rules` — SUCCESS, 16 rules.

Третье правило того же семейства (PostgresDeadTuplesHigh) верно, но верно
случайно: печатаемая величина совпала с левым операндом. Помечено комментарием,
чтобы его не «причесали» по образцу двух других.

Гейт: backend/tests/ops/test_alert_value_is_the_described_quantity.py — если
описание рендерит `$value` как долю (`humanizePercentage`), левая часть
выражения обязана содержать деление. Фальсификация: вернул файл правил с
origin/main → красные test_percentage_annotations_come_from_a_ratio и
test_known_two_rules_are_fixed; с правкой — 3 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:27:02 +05:00
091137a0c5 Быстрый путь caddy_only: считаем изменённые файлы сами, без исключающих шаблонов (#3448)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m34s
CI / backend-tests (pull_request) Successful in 17m41s
Быстрый путь «правка ТОЛЬКО прокси» (#2916) не отработал ни разу: мерж
84920e6c, где в диффе один файл caddy/sites/apps.caddy, пересоздал весь стек
ПТИЦЫ (gendesign-caddy-1 Created=2026-09-11T20:40:14, в логе Caddy
"serving initial configuration" — холодный старт, а не reload).

ПРИЧИНА НЕ ТА, ЧТО В ГИПОТЕЗЕ. Гипотеза #3448 — пустой github.event.before у
мерж-коммита — опровергнута логом задачи 29244 (run 10881):

    Changes will be detected between 204e2e09de and main
    git diff --no-renames --name-status -z 204e2e09..refs/remotes/origin/main
    M caddy/sites/apps.caddy
    Detected 1 changed files

before валиден, коммит дотянут, дифф верный. Дальше в том же логе:

    ##[group]Filter non_caddy = true
    Matching files:
    caddy/sites/apps.caddy [modified]

Исключённый файл сам себя и «исключил». dorny/paths-filter склеивает шаблоны
одного фильтра через some, то есть ИЛИ (src/filter.ts: patterns.some(aPredicate),
predicate-quantifier по умолчанию some), поэтому

    non_caddy: ['**', '!Caddyfile', '!caddy/**']

читается как «подходит под ** ИЛИ не Caddyfile ИЛИ не caddy/**» — а ** матчит
всё. non_caddy был true ВСЕГДА, caddy_only — false всегда, deploy-caddy
пропускался. Сигнала не было ни одного: пропущенную джобу Forgejo рисует
зелёной, и «зелёный deploy-caddy» неотличим от невыполненного.

ЧТО СДЕЛАНО. Job changes считает список файлов сам: git diff по явным границам
(before → HEAD), флаги backend/frontend/infra/caddy_only выводятся из этого
списка. Заплатки к фильтрам не годятся: predicate-quantifier: every действует
на ВЕСЬ блок и сломал бы backend/frontend/infra, то есть фикс снова висел бы на
незаметном умолчании.

Шаг ПЕЧАТАЕТ и список файлов, и итоговые флаги — у правки должен быть
наблюдаемый признак, иначе «сработало» и «просто не совпало» выглядят одинаково.
База не разрешилась (ручной запуск, пустой/нулевой before, коммита нет в клоне)
→ изменённым считается весь репозиторий: лишний полный деплой безопаснее
пропущенного. Фолбэка на HEAD^..HEAD намеренно нет — у push'а из нескольких
коммитов он молча урезал бы список и включил быстрый путь там, где приехал бэкенд.

Гейт backend/tests/ops/test_3448_caddy_only_detection.py ИСПОЛНЯЕТ этот шаг на
временном репозитории с настоящим мерж-коммитом и проверяет значения флагов:
только caddy → caddy_only=true; caddy+backend → false; база не разрешилась →
полный деплой; решение видно в логе. Отдельная проверка ловит класс бага во всех
воркфлоу — исключающие шаблоны '!' в любом paths-filter без predicate-quantifier: every.

Приёмка на проде: следующий мерж с единственным файлом под caddy/** не меняет
docker inspect gendesign-caddy-1 --format '{{.Created}}', а в логе Caddy —
reload, а не "serving initial configuration".

Closes #3448

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:26:52 +05:00
5f2810b8c6 Витрина «сделки против объявлений» перестаёт пустовать: снят предикат по синтетической комнатности сделок (#3451)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 38s
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Successful in 4m35s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 1m31s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-12 09:03:05 +00:00
be9aa2f907 Гейт на ВСЕ 34 проводки + запрет вложенных бюджетов (ревью #3460)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Сценарный тест ловил одну проводку из 34 — ту, через которую сам и шёл
(`geocoder._cache_get`). Мутационный прогон ревьюера: возврат голого
`asyncio.to_thread` в 5 из 6 других мест тест НЕ краснит, то есть регресс
«кто-то вернул вызов в голый вид» прошёл бы мимо CI в 33 случаях из 34.

`test_no_bare_to_thread_over_request_session` читает исходники geocoder и
estimator (через `module.__file__`, не по относительному пути — он зависел бы
от cwd прогона) и требует нуля живых `asyncio.to_thread(`. Оба модуля сейчас на
нуле, поэтому гейт без списка исключений. Фальсификация — голый `to_thread` у
`_fetch_anchor_comps` (estimator:4973, сценарным тестом не покрыт): гейт
краснеет с номером строки.

Второе: защита `run_db_thread` одноразовая — `except asyncio.CancelledError`
ловит ОДНУ отмену, вторая вылетает из самого `asyncio.wait([step])`, и поток
остаётся сиротой. Живых путей нет (`_with_budget` нигде не вложен, Starlette не
отменяет задачу на дисконнекте, uvicorn стартует без
`--timeout-graceful-shutdown`), поэтому кода не трогаю — фиксирую инвариант
«не вкладывать бюджеты» в докстринге `_with_budget`, чтобы вложение не завезли
как безобидное.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:57:49 +05:00
de2d67c7f9 docs(mera/sales-vs-listings): якорь и докстринг по замечаниям ревью PR #3461
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
Ревью справедливо поймало два места, где текст после снятия предиката стал
неточным:

1. Якорь в deploy/import-rosreestr.sh обещал «потребителей, фильтрующих по
   d.rooms, больше нет» — это верно только про предикаты РАВЕНСТВА.
   app/tasks/asking_to_sold_ratio.py:148,152 по-прежнему КЛЮЧУЕТСЯ этим
   бакетом (GROUP BY LEAST(GREATEST(rooms,0),4)) и намеренно зеркалит ту же
   синтетику на листинговой стороне (#2620). Прежняя формулировка сказала бы
   будущему редактору, что проверять некого, — а в сценарии «поменяли CASE на
   реальную комнатность» вернулся бы именно #2620.

2. Докстринг GET /sales-vs-listings обещал listing «с такими же rooms». После
   снятия предиката это верно для пары запрос↔объявление, но не для пары
   сделка↔объявление: deal_rooms может не совпадать с запрошенным rooms.

Кода правка не касается. Полный сьют: 5946 passed, 35 skipped; ruff чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:55:40 +05:00
b71f3f9957 fix(tradein): коридор ДКП с малым числом сделок помечен справочным
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m16s
CI Trade-In / backend-tests (pull_request) Successful in 5m32s
Показ коридора открывается с трёх сделок (DKP_CORRIDOR_CITY_WIDE_MIN_N), а обе
ценовые страховки по нему — soft-кламп headline сверху и radius-floor снизу —
включаются с десяти (estimate_corridor_clamp_min_n). В зоне n=3..9 коридор
существует, показывается и участвует в fallback-путях, но цену не держит, и на
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
d.rooms) переносит туда реальных клиентов: замерено 248 → 3 и 72 → 8 сделок.

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

- DkpCorridor.advisory_only — computed-поле от count и ЕДИНСТВЕННОГО порога
  estimate_corridor_clamp_min_n, так что верно во всех конструкторах коридора
  (POST /estimate и GET-rehydrate) и не дублирует порог вторым числом;
- лог INFO с маркером corridor_advisory_zone (n, порог, scope street/city_wide,
  id оценки) — одна строка на оценку, считается за сутки одним grep -c;
- _fetch_dkp_corridor отдаёт служебный ключ scope: «мало сделок на улице» и
  «мало сделок во всём городе» — разные новости, и лог обязан их различать;
- на экране (v1 hero + плитка ДКП в v2) подпись «справочно: мало сделок —
  оценку по ним не корректировали». Подпись молчит, когда headline ПОСТРОЕН из
  этого же коридора (n_analogs = 0, deals-fallback): там показанная цена и есть
  медиана этих сделок, и подпись была бы ложью в другую сторону.

Тесты по значению (test_3452_corridor_advisory_zone.py) гоняют настоящий
estimate_quality с коридором, потолок которого заведомо ниже медианы аналогов:
в зоне headline НЕ прижат и метка стоит, выше порога — прижат к cap и метки
нет. Захардкоженный флаг в любую сторону и снятый порог клампа роняют тесты
(проверено руками).

Closes #3452

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:42:43 +05:00
4053adad06 fix(mera/sales-vs-listings): снят предикат d.rooms в TVF — он был вторым фильтром по площади (#3451)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m23s
`street_sales_vs_listings()` фильтровала сделки по `d.rooms`, а `deals.rooms` у
источника 'rosreestr' — не комнатность, а бакет площади: импортёр пишет туда
`CASE WHEN area < 30 THEN 0 ... ELSE 4 END`, и на проде 321 559 строк из 321 560
удовлетворяют `rooms == area_bucket(area_m2)`. Предикат работал вторым фильтром
по площади поверх полосы ±15 %, которую функция считает сама: клиенту 49 м² / 1к
полоса 41.7–56.4 м² урезалась до «меньше 44 м²».

Ровно эта патология снята в #3256 (PR #3445) на четырёх сделочных площадках
эстиматора. Здесь — последний оставшийся потребитель, и ключ асимметричный:
`d.rooms` снят, `l.rooms` ОСТАВЛЕН (у объявлений комнатность настоящая, это
единственный признак ассортимента на листинговой стороне).

Миграция 300 = тело 211 минус ровно одна строка; сигнатура и RETURNS TABLE
побайтово те же (иначе CREATE OR REPLACE создал бы вторую перегрузку, #2627),
сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно.

Прод-замер (2026-09-12, 1160 реальных клиентских запросов из trade_in_estimates,
улица извлеклась у 954; тем же путём, что у продукта — extract_street_name /
_resolve_target_city):
  - непустой ответ /sales-vs-listings: 805 (84.4 %) → 899 (94.2 %), впервые
    непустых 94 клиента;
  - сделок в выборке: 68 147 → 88 816;
  - из них с подобранным объявлением (то, что показывается парами): 30 830 → 39 193;
  - выборка не сократилась ни у кого (0 из 954) — предикат умел только резать.
Прогноз в issue был «те же 180 клиентов»; измерено 94 — оценка 180 бралась по
коридору эстиматора с другими period/tolerance, в файл положено измеренное.

Тело проверено EXPLAIN'ом на боевой БД (только чтение) — планировщик принимает.

Тесты: tests/test_migration_300_sales_vs_listings_deals_rooms.py — статические
гарды. Фальсификация обоих направлений: вернул `d.rooms = p_rooms` → красные
test_deals_side_has_no_rooms_predicate + test_only_the_rooms_predicate_differs_from_211;
снял заодно `l.rooms = p_rooms` («починил симметрично») → красный
test_listings_side_keeps_rooms_predicate. Полный сьют: 5946 passed, 35 skipped.

Якорь в deploy/import-rosreestr.sh обновлён: потребителей, фильтрующих по
d.rooms, больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:25:17 +05:00
c251c02f1e Отмена по бюджету больше не оставляет сироту в сессии запроса (#3449)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m24s
`asyncio.to_thread` отменить нельзя: по истечении бюджета (`_with_budget` =
`asyncio.wait_for`, у геокодера 12 с) снимается только ожидание со стороны
loop'а — поток продолжает работать с ТОЙ ЖЕ `Session`, что и весь запрос.
Вызывающий тем временем идёт дальше: следующий источник, `_fetch_anchor_comps`,
`_persist_estimate_and_commit`. Два потока в одной `Session` дают «another
operation is in progress» / InvalidRequestError на СЛЕДУЮЩЕМ шаге. У источников
эту ошибку глушит `except` вокруг вызова, у персиста оценки не глушит никто —
500 и потерянная оценка клиента.

`app/core/db.py: run_db_thread` — ТОЛЬКО защита от сироты: `ensure_future` +
`shield`, на отмене дождаться потока (`asyncio.wait`), прочитать
`step.exception()` (иначе asyncio печатает «Task exception was never
retrieved» без контекста) и пробросить отмену. Commit/rollback туда НЕ вынесены:
посреди геокодинга commit зафиксировал бы частичное состояние оценки.
`estimator._db_step` переписан поверх и добавляет свои commit/rollback сам —
его поведение не меняется, гейт tests/test_3408_db_step_cancel_orphan.py
остаётся зелёным.

Заменено 34 вызова, работающих по сессии запроса: 12 в geocoder.py (кэш-чтение
и записи, геопортал, кадастр, houses, reverse, suggest), 19 в estimator.py
(в т.ч. `_backfill_house_fias`, `_save_yandex_history_items`,
`_fetch_anchor_comps`, `_price_from_inputs` с db-резолверами, персист оценки,
`_fetch_price_trend`, `_is_premium_building`), 2 в api/v1/geocode.py, 1 в
api/v1/privacy_admin.py. Не тронуты вызовы со СВОЕЙ сессией:
`user_events.schedule_event` (внутри `record_event` свой `SessionLocal`) и
`sber_index` (сессия задачи планировщика, отменять её некому).

Гейт по значению — tests/test_3449_geocoder_cancel_orphan.py: отмена по бюджету
во время шага БД геокодера, следом ГОЛЫЙ `to_thread(db.execute, ...)` (образец
персиста); проверяется, что он не вошёл в сессию, пока сирота ещё в ней.
На исходном коде тест краснеет: conflicts == 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:20:44 +05:00
9fa01e7ebe Merge pull request 'Поддержка: доставленное сообщение не теряется при сбое БД, отказы Telegram расходуют бюджет' (#3459) from fix/tg-support-db-and-ratelimit into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m14s
Deploy Trade-In / test (push) Successful in 4m27s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 7m51s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-12 08:00:18 +00:00
bot-backend
10ffa93a1c fix(support): доставленное сообщение не теряется при сбое БД, отказы Telegram расходуют бюджет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m11s
CI Trade-In / backend-tests (pull_request) Successful in 5m26s
Два последних дефекта из разбора телеграм-стека, оба в ручках веб-поддержки.
Предыдущие три PR (#3456, #3457, #3458) чинили клиент и мост; эти — сами ручки.

## Сбой БД уже ПОСЛЕ доставки в топик

Порядок «сначала Telegram, потом БД» осознанный, но блок записи не был обёрнут
ничем, в отличие от шага отправки. `SQLAlchemyError` там означал: сообщение
оператору доставлено, а клиент получил 500. Дальше по цепочке — пользователь
шлёт повторно, в топике дубль, а на осиротевшее зеркало оператор отвечает в
пустоту, потому что треда в БД нет и мост на реплай пишет только WARNING.

Обе ручки теперь ловят `SQLAlchemyError` вокруг блока БД, тихо откатывают
сессию, предупреждают оператора реплаем к доставленному зеркалу и отдают
клиенту успех. Успех, а не отказ: доставка правда состоялась, и отказ
спровоцировал бы ровно тот дубль, которого избегаем.

Анонимная ветка на этом пути дополнительно ставит куку, хотя штатно ставит её
только на успехе: треда нет, но идентичность посетителя обязана пережить сбой,
иначе следующее сообщение заведёт второй тред.

## Успеха мало — клиент должен об этом узнать

Первая версия правки отдавала успех молча, и это было неотличимо от тишины.
Фронт выбрасывает тело POST и рендерит переписку только из GET, а сообщения там
нет: поле ввода очищается, в списке пусто, баннера нет. Пользователь решает, что
не отправилось, и шлёт снова — тот самый дубль. Нашло adversarial-ревью, и это
подтверждено чтением `useSupportChat.ts` и `SupportChatPanel.tsx`.

Поэтому `SupportMessageOut` получил поле `persisted` со значением `True` по
умолчанию — все существующие пути и `GET /support/messages` отдают его без
изменений. На пути деградации приходит `False`, и панель показывает рядом с
композером предупреждение: сообщение получено оператором, но в переписке его не
будет, отправлять ещё раз не нужно. Баннер гаснет на следующей нормально
записанной отправке. Анонимный виджет рендерит ту же панель и получает это
поведение автоматически.

Текст предупреждения оператору тоже переписан: он больше не рассчитывает на то,
что клиент напишет снова, и прямо говорит, что ответить через бота не получится.

## Рейт-лимит переставал считаться при недоступном Telegram

`retry_after()` — это peek, а `record()` звался только на успехе. Верно для
«не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram лежит,
лимита нет вообще, и каждый повтор стоит до четырёх попыток к api.telegram.org,
не расходуя ни один бюджет. Двух-трёх вкладок с авто-повтором хватает, чтобы
выесть лимиты группы ровно тогда, когда канал и так еле жив.

Добавлен отдельный счётчик отказов на тех же ключах: пять подряд в окне тридцати
секунд включают cooldown, и ручка отвечает 429 не доходя до Telegram. Пять
подряд на живом канале практически недостижимы, а `reset()` на успехе стирает
историю — считаем именно подряд. Тридцать секунд заведомо короче реальной
недоступности, так что после восстановления пользователя не наказывают.
Основной «успешный» бюджет и non-destructive peek не тронуты.

`SlidingWindowLimiter.reset(key)` добавлен аддитивно, с оговоркой в докстринге,
что лимитерам-бюджетам он противопоказан.

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

## Тесты

Отказ БД в обеих ручках: клиент получает успех с `persisted=False`, оператору
уходит предупреждение, текст обращения в него не попадает, 500 не возникает.
Отказ самого уведомления ручку не роняет. Серия отказов включает cooldown, и до
Telegram запрос не доходит. Окончание окна cooldown снимает. Успешный путь и
существующий рейт-лимит не изменились.

Бэкенд: 117 passed, ruff чистый. Фронт: type-check чистый, lint без новых
замечаний.
2026-09-12 10:53:45 +03:00
a3d4fcf0b3 Merge pull request 'Связь с Telegram не встаёт колом, ответ оператора не теряется' (#3458) from fix/tg-connection-resilience into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 1m30s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
2026-09-12 07:20:20 +00:00
bot-backend
1fa65eba6b fix(tg): связь с Telegram не встаёт колом, ответ оператора не теряется
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m19s
Замер прода за сутки 12.09.2026: 576 строк `network error` в логе `tradein-tgbot`
и 7 полных исчерпаний бюджета ретраев, после которых падала итерация poll loop.
Три причины, все подтверждены на коде и в рантайме.

## Ответ оператора мог пропасть навсегда

`process_update` заканчивался безусловным `finally: save_offset(update_id)`.
Замысел верный — «ядовитый» апдейт не должен блокировать поток, — но он не
отличал неисправимый апдейт от транзиентного сетевого отказа. Оператор отвечает
клиенту в топике, `copy_message` падает по сети, `TelegramNetworkError` улетает
в общий `except Exception`, offset сдвигается. Telegram этот апдейт больше не
отдаст, `record_message` не выполнился, оператор уверен, что ответил. Следа нет
нигде, кроме строчки в логе.

Теперь `process_update` возвращает `bool`. На `TelegramNetworkError` делается
`rollback()`, offset НЕ сохраняется, возвращается `False`, и `run_poll_loop`
прерывает разбор пачки — offset у Telegram единая «высшая отметка», подтверждение
любого следующего апдейта неявно подтвердило бы и этот. Остаток пачки Telegram
отдаст заново.

Переигрывания ограничены сверху `_MAX_NETWORK_REPLAYS = 3`: без потолка «вечно
недоставляемый» апдейт заклинил бы очередь навсегда, а это хуже потери одного
сообщения. На потолке offset всё-таки двигается, но с `logger.error` и с
`chat_id`/`message_id`, по которым человек найдёт ответ в топике и перешлёт
руками. Текст переписки в лог по-прежнему не идёт.

Дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев, при этом
запрос мог дойти до Telegram, а ответ потеряться. Переигрывание тогда доставит
сообщение второй раз. Это осознанный at-least-once компромисс — дубль видят и
клиент, и оператор, а тихая потеря не видна никому. Полная идемпотентность по
паре (update_id, target_chat_id) потребовала бы новой персистентной таблицы ради
редкого случая; вместо неё число дублей жёстко ограничено сверху.

Ветка `except TelegramApiError` с разбором `error_code == 403` («бот заблокирован»)
не тронута — там повтор действительно ничего не изменит.

## Таймаут задавался скаляром, поэтому connect ждал сорок секунд

`httpx.AsyncClient(timeout=effective_timeout)` разворачивается в
connect=read=write=pool. Для `getUpdates` бюджет ответа 40 секунд (30 держит
Telegram плюс запас), и те же 40 секунд уходили на установку соединения — при
живом connect в 0.036 секунды. Худший цикл: четыре попытки по 40 секунд плюс
backoff, около трёх минут, в течение которых бот не видит ответов оператора.
В логе это ровно те разрывы: 06:40:10, 06:42:22, 06:43:35.

Теперь `httpx.Timeout(connect=5, read=<бюджет вызывающего>, write=10, pool=5)`,
значения в именованных константах. Запас `+10s` у `get_updates` относится к read,
докстринг поправлен.

## Клиент создавался заново на каждую попытку

`httpx.AsyncClient` стоял ВНУТРИ цикла ретраев — keep-alive не было вовсе: полный
TCP+TLS-хендшейк на каждый запрос и на каждый повтор, и заново кидался кубик
«встанет ли коннект». Для long-polling это была основная статья сетевых отказов.
Плюс три HTTP-ручки создавали `TelegramClient` на каждый входящий запрос.

Теперь один ленивый переиспользуемый `AsyncClient` на экземпляр, с `aclose()` и
`async with`. Общий клиент приложения живёт в новом `app/services/tgbot/shared.py`,
создаётся и закрывается в lifespan; воркер бота держит свой на время поллинга.
`keepalive_expiry` задан явно: дефолт httpx — 5 секунд, и с ним пул не давал бы
ничего там, где нужнее всего. Poll loop переиспользует соединение и так, а вот
веб-поддержка шлёт раз в минуты и за 5 секунд теряла бы его каждый раз. Плата за
длинный keep-alive — шанс взять из пула закрытое той стороной соединение; httpx
отдаёт это как `RemoteProtocolError`, который ретраится с #3457.

## Уведомления оператору шли с воркерным бюджетом внутри poll loop

Обе отправки в топик («бот заблокирован», «веб-чат не поддерживает медиа») звались
без своего бюджета, то есть с дефолтом в 5 ретраев и backoff до 30 секунд. Одна
такая отправка стопорила весь цикл на минуты, а её отказ решал судьбу апдейта.
Вынесены в `_notify_topic` с узким бюджетом и собственным `except`: провал
вторичного действия больше не отменяет основную ветку.

## Тесты

`tests/services/tgbot/test_shared.py` — новый, на жизненный цикл общего клиента.
В `test_bridge.py` — сетевой отказ оставляет offset нетронутым и апдейт
переигрывается, потолок разблокирует поток, отказ уведомления не отменяет основную
ветку, прежнее поведение на 403 не изменилось. В `test_client.py` — раздельные
таймауты доезжают до httpx per-request, два вызова используют один `AsyncClient`,
`aclose()` его закрывает.

Прогон по затронутым файлам: 127 passed. Ruff check и format чистые.

Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика,
и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно.
2026-09-12 10:13:44 +03:00
8994e041cf Merge pull request 'Недоступный Telegram отдаёт 502 — теперь на всём дереве транспортных отказов' (#3457) from fix/tg-transport-error-502 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m19s
Deploy Trade-In / deploy (push) Successful in 7m30s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
2026-09-12 06:26:30 +00:00
bot-backend
087c48fef5 fix(tg): ретраим весь TransportError, остальной RequestError → 502 без ретраев
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
Follow-up к #3456. Тот PR научил три HTTP-ручки ловить общий `TelegramError` и
отдавать 502, но закрыл дыру не до конца: клиент по-прежнему выпускал наружу
сырой httpx. Ретраящийся `except` перехватывал узкий кортеж
`(httpx.TimeoutException, httpx.NetworkError)`, а `RemoteProtocolError`,
`ProxyError`, `LocalProtocolError` и `UnsupportedProtocol` — не наследники
`NetworkError`, а сёстры по `TransportError`. Проверено запуском на httpx 0.28.1,
не по памяти.

Практическое следствие — ровно тот отказ, который #3456 и чинил.
`RemoteProtocolError` («Server disconnected without sending a response») для
api.telegram.org из РФ — бытовой ответ, а не экзотика. Он вылетал из `_request`
сырым, проходил мимо `except TelegramError` в glitchtip.py:227 и support.py:233
и :424, и FastAPI снова отдавал 500. Глобального обработчика, который поймал бы
его выше, нет: в `core/http_errors.py` зарегистрирован только
`RequestValidationError`. Вдобавок такой отказ не ретраился ни разу — вылетал с
первой попытки, без backoff и без строки лога о сетевом сбое, так что в проде
отличить его от исчерпания бюджета было нечем.

Теперь два `except`, и вместе они покрывают всё дерево отказов запроса.
Ретраящийся расширен до `httpx.TransportError` — тело не тронуто, те же reason,
backoff, лог и `TelegramNetworkError` из #3156. Ниже страховочный
`httpx.RequestError` без ретраев: сегодня это `DecodingError`, завтра — всё, что
httpx заведёт под `RequestError`. Порядок значим — `TransportError`
наследник `RequestError` и обязан стоять выше, иначе сетевые отказы перестали бы
ретраиться. Повторов у страховочного нет намеренно: испорченный ответ и кривую
конфигурацию повтор не лечит, а пять попыток с backoff подвесили бы
интерактивную ручку почти на минуту впустую.

Расширение ретраев на `RemoteProtocolError` наследует уже принятый в этом клиенте
риск at-least-once: запрос мог дойти до Telegram, а ответ потеряться. Риск тот
же, что у давно ретраящегося `ReadTimeout`, политика не меняется.

Прецедент лова именно `TransportError` в этом же репозитории —
`app/services/payments/tbank_client.py:136`.

Не тронуто: ручки (они уже ловят предок), `bridge.py` (`except TelegramApiError`
там намеренный — разбор 403 «бот заблокирован»), `_extract_retry_after`,
обработка 429/5xx, потолки backoff.

Тесты: прежний тест «наружу свой тип» параметризован по `ConnectTimeout`,
`RemoteProtocolError`, `ProxyError`, `DecodingError` с ожидаемым числом попыток;
новый тест фиксирует разницу бюджета — обрыв протокола ретраится, битый ответ нет.
Прогон по четырём затронутым файлам: 80 passed.
2026-09-12 09:19:42 +03:00
ec245cf2b3 Merge pull request 'Недоступный Telegram отдаёт 502, а не 500' (#3456) from fix/tg-network-error-502 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m20s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 3m8s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-12 00:08:46 +00:00
bot-backend
46326ba96e fix(tg): недоступный Telegram отдаёт 502, а не 500
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
Прод 11.09.2026, 01:35 и 01:38 MSK — два 500 на glitchtip-webhook. Причина не
в вебхуке: `TelegramClient._request` после исчерпания сетевых ретраев делал
голый `raise`, наружу летел `httpx.ConnectTimeout`. Все три HTTP-ручки ловят
`TelegramApiError` — сырой httpx пролетал мимо, и FastAPI отдавал 500 вместо
задуманного 502. Отказ площадки и её недоступность для вызывающего
неразличимы: переслать не смогли и там, и там.

Клиент больше не выпускает наружу чужой тип. Появился общий предок
`TelegramError`, под ним прежний `TelegramApiError` (ответили `ok: false`) и
новый `TelegramNetworkError` (не ответили вовсе). Раздельно, а не наследником,
потому что у сетевого отказа нет ни `error_code`, ни `description` — брать их
неоткуда, а `bridge` по `error_code == 403` разбирает «бот заблокирован» и
недоступность в этот разбор попадать не должна. Причина сохраняется в
`__cause__`: в GlitchTip по-прежнему видно, таймаут это соединения или сброс
TLS (#3156).

Три ручки — вебхук GlitchTip и обе ручки поддержки, авторизованная и
анонимная — ловят предок. Поведение воркеров не менялось: poll loop в
`bridge` и так ловит `Exception`, бюджеты ретраев те же.

Тесты: два в клиенте (свой тип наружу, причина не потеряна, это НЕ
`TelegramApiError`), три на ручках (502 на недоступности, ничего не
персистится, анонимной куки не выдаём). Четыре теста бюджета ретраев ждали
`httpx.ConnectTimeout` — ждут новый тип, проверяемые паузы прежние.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-12 03:02:03 +03:00
b14f21aa78 Merge pull request 'Сборщик: обрыв сети на машине не должен убивать многочасовой проход' (#3455) from fix/msk-collector-net-retry into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 1m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-11 23:42:08 +00:00
bot-backend
1bcc922e80 fix(msk-collector): сетевой обрыв на машине больше не убивает многочасовой проход
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
Полный проход Яндекса по Москве умер 12.09 на восьмом часу:
`Page.goto: net::ERR_NETWORK_CHANGED`. Площадка была ни при чём — сразу после
и realty.yandex.ru, и git.gendsgn.ru отвечали 200, а в ту же минуту по DNS
отвалился и MCP-сервер ошибок. Моргнула сеть на самой машине. Ретрай в `_goto`
ловил только `PlaywrightTimeoutError`, поэтому обычная сетевая ошибка уходила
наверх и роняла прогон целиком.

Сетевые отказы Chromium вынесены в `_TRANSIENT_NET_ERRORS` и считаются СВОИМ
счётчиком: пять попыток с нарастающим ожиданием 15/30/45/60/120 с. Бюджет
попыток по таймауту при этом не тратится — обрыв сети длится минуты, а таймаут
навигации это совсем другой симптом. Исчерпали — стоп с причиной `nav_network`,
прогон продолжается тем же batch-id с --resume. Всё, что не в списке,
по-прежнему поднимается: тихий отказ площадки выглядит ровно так же, и молча
проглоченный он превращается в пустой прогон.

Тестов у сборщика не было вообще, при том что он уже дважды ронял многочасовой
прогон на ошибке, которая отказом площадки не была. Заведён первый файл: он
подгружает скрипт по пути (нет на месте — skip, не красный) и караулит границу
«наше или ихнее» — распознавание сетевых ошибок, переживание короткого обрыва,
остановка при длинном с правильной причиной, проброс незнакомой ошибки и
неизменность прежнего пути по таймауту.

Полный сьют бэкенда — 5902 passed, 37 skipped; ruff чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-12 02:35:42 +03:00
ba17c868d4 Merge pull request 'Импортёр знает Яндекс: город из второго компонента адреса, ноль внешних вызовов' (#3454) from feat/msk-yandex-import into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m55s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-11 23:26:15 +00:00
bot-backend
ce9c45c3c2 feat(msk): импортёр знает Яндекс — город из адреса, ноль внешних вызовов
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
Сбор Яндекса по Москве уже идёт, а лить его было нечем: в SOURCE_VIEWS стояли
только cian и avito.

Отбор Москвы у Яндекса не требует ни префикса округа (как у Циана), ни
пред-геокода (как у Авито). Адрес приходит полным и нормализованным — «Россия,
Москва, Коробейников переулок, 1», регион читается вторым компонентом. Замер по
21 393 карточкам первого прохода: во втором компоненте ровно ДВА значения,
«Москва» 10 610 и «Московская область» 10 783, третьего не встречается. Новая
Москва отдельным значением не приходит — Троицк и Зеленоград Яндекс кладёт под
«Москва», что совпадает с кодом региона 77.

Координаты, адрес и ссылка заполнены у 100% карточек, поэтому geom появляется
сразу и ждать ночного `geocode_missing` не нужно. `--geocode` для yandex
отклоняется так же, как для cian: квота нужна только Авито.

`filter_by_okrug` заменён словарём CITY_FILTERS — источник либо сам говорит про
город, либо его в словаре нет и без пред-геокода писать его нельзя. Поведение
cian и avito байт в байт прежнее.

`uv run python -m pytest tests/test_msk_raw_import.py` — 42 passed, ruff чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-12 02:19:44 +03:00
deb6517bd5 Merge pull request 'Красный main после #3440: тест полос по округам передаёт убранный параметр rooms' (#3453) from fix/3051-fetch-deals-rooms-dropped into main
All checks were successful
Deploy Trade-In / test (push) Successful in 4m43s
Deploy Trade-In / deploy (push) Successful in 6m17s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-09-11 23:01:32 +00:00
bot-backend
37494dd74a fix(msk): тест полос по округам разъехался с main — у _fetch_deals нет rooms
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
Post-merge прогон main после #3440 дал 5 failed: `_fetch_deals() got an
unexpected keyword argument 'rooms'`. Семантический конфликт мержа, а не
поломка: ветка отводилась до #3256, который УБРАЛ параметр `rooms` (сделки
Росреестра комнатность не несут). Текстового конфликта git не увидел, pre-merge
CI ветки был зелёным, красным стало только на объединённом дереве.

В проде эффекта нет: `_fetch_deals` из `app/` не вызывается ни разу ни до, ни
после мержа — функцию держат только эти тесты.

`uv run python -m pytest tests/test_3051_moscow_okrug_bands.py` — 24 passed,
ruff чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-12 01:53:33 +03:00
a99a9b870c Merge pull request 'Москва: пред-геокод Авито, Яндекс третьей площадкой, продукт отвечает по региону 77' (#3440) from feat/msk-collector-cian into main
Some checks failed
Deploy Trade-In / test (push) Failing after 4m18s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
2026-09-11 22:30:11 +00:00
c4315b3dfb Merge pull request 'fix(mera/estimate): убран предикат d.rooms в коридоре ДКП — он был вторым фильтром по площади (#3256)' (#3445) from fix/3256-asking-to-sold-buckets into main
Some checks failed
Deploy Trade-In / build-browser (push) Successful in 47s
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 2s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Failing after 4m18s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
2026-09-11 21:59:18 +00:00
bot-backend
86cba37218 docs(#3256): докстринг коридора без «та же rooms»; якорь называет оставшегося потребителя (TVF 211)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
2026-09-12 02:53:03 +05:00
6df6f92a2b test(estimator): полоса площади — единственный фильтр похожести, а не просто «есть»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
`test_dkp_corridor_keeps_full_area_band` проверял только присутствие полосы ±15% в
bind-параметрах — она есть и на origin/main, и на варианте с бакет-ключом, поэтому
тест был зелёным по построению и ничего не охранял (мутационная проверка: при
восстановлении предиката он оставался зелёным, пока остальные 4 краснели).

Утверждение усилено до «полоса единственная»: тест дополнительно требует отсутствия
предиката по rooms рядом с ней. На origin/main фильтров по площади ДВА (полоса и
бакет через d.rooms), итоговое окно — их пересечение, поэтому теперь тест краснеет
значением вместе с остальными.

Refs #3256
2026-09-12 02:39:28 +05:00
4efcb712e4 Merge pull request 'fix(mera/estimate): коннект БД не живёт через внешний HTTP; потолок пула ≥ суммы потолков одновременности (#3083, #3408)' (#3444) from fix/3083-estimate-throughput into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m14s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 2m44s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-11 21:33:14 +00:00
16d99e0f1a fix(estimator): убрать предикат по deals.rooms, а не подставлять в него area-бакет
Разворот предыдущего коммита ветки (a780e3e6) на корень: вместо подстановки
`area_bucket(area)` в предикат `d.rooms = ...` предикат УДАЛЁН во всех трёх местах.

ПОЧЕМУ НЕ БАКЕТ. `deals.rooms` — синтетика из площади (321 559 из 321 560 сделок
удовлетворяют `rooms == area_bucket(area_m2)`, max(rooms)=4), значит `d.rooms = X`
тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`. Это ВТОРОЙ, ступенчатый фильтр
по площади поверх полосы `area_m2 BETWEEN :area_min AND :area_max`, стоящей строкой
ниже. Прод-замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12) — какая
доля полосы ±15% переживает предикат:

  d.rooms = комнаты клиента   медиана 77.8%, у 180 запросов полоса вырезана ЦЕЛИКОМ
                              (пересечение пусто ⇒ коридора нет никогда)
  d.rooms = area_bucket(area) медиана 90.0%, пустых нет, НО у 902 из 1179 полоса
                              всё ещё усечена: 44.0 м² → сохраняется 50% полосы,
                              62.0 м² → 50%, 82.6 м² → 59.7%. Величину усечения
                              задаёт не модель, а случайное положение метража
                              относительно границ 30/44/62/85.
  без предиката               100% по построению

Т.е. бакет-ключ чинит катастрофический случай (пустое пересечение) и оставляет
произвольное усечение у 76.5% запросов. Полоса ±15% уже выражает «похожие по
площади сделки» — второго фильтра по тому же признаку быть не должно.

ЗАМЕР ЭФФЕКТА НА ЦЕНУ (1179 запросов, все три пути влияния коридора на headline:
cap/floor, sufficiency-гейт #oblast-E, deals-headline-fallback; листинговая сторона
берётся из сохранённой оценки, коридор пересчитан на сегодняшнем снимке deals для
всех вариантов, поэтому сравнение apples-to-apples; реплика сверена с ПРОДОВЫМ SQL
на 58 оценках × 3 варианта — 174/174 совпадений):

  коридор доступен   n>=3: 769 → 850 (бакет, +86/−5) → 874 (без ключа, +105/−0)
                    n>=10: 567 → 623 (бакет, +66/−10) → 691 (без ключа, +126/−2)

  сдвиг headline vs текущий прод   бакет            без ключа
    клиентов сдвинулось            28               120
    медиана сдвига                 +1.0%            −1.6%
    p10 / p90                      −30.6% / +6.1%   −8.6% / +4.3%
    сдвиг > ±10%                   10 (все вниз)    10 (7 вниз, 3 вверх)
    сдвиг > ±25%                   4                2

  по путям (медиана сдвига):       бакет            без ключа
    cap/floor, радиусная медиана   +3.9% (p10 −36.3%)   −1.7% (p10 −5.9%)
    cap/floor, якорь Tier C        −10.1% (5 сдвигов, 4 из них >10% вниз)  −0.8%
    sufficiency-гейт               −0.4%            +0.3%
    deals-fallback                 +3.5% (p10 −20.8%)   −0.1%
    якорь Tier A                   0 (коридор не влияет: cap exempt, floor требует
                                      anchor_tier is None)

Вариант без ключа даёт больше покрытия (+105/−0 против +86/−5), сдвиг с медианой
около нуля и БЕЗ кластера сильных падений, тогда как бакет-ключ несёт кластер
Tier C с медианой −10.1%. Худший случай (−41.8%, Малышева 84, 1к/54 м²: премиальный
лот прижимается cap'ом к коридору улицы) ОБЩИЙ для обоих вариантов — он появляется
от самого факта наличия коридора, а не от выбора ключа.

УТОЧНЕНИЕ ФАКТА ИЗ a780e3e6: «у 818 клиентов выборка не меняется» — неверно, их
793. Скрипт классифицировал через `min(max(rooms,0),4) == area_bucket`, из-за чего
27 клиентов с 5-6 комнатами попали в «совпадающие», хотя у них выборка меняется с
пустой на непустую. (Практического прироста они всё равно не получают: их метраж
158-456 м² в основном вне окна импорта `area BETWEEN 18 AND 200`.)

ЯКОРЬ ПРОТИВ МОЛЧАЛИВОГО ВОЗВРАТА. Ни один тест не краснел, если импортёр начнёт
писать настоящую комнатность. tests/test_3256_deals_rooms_key.py теперь ПАРСИТ CASE
из deploy/import-rosreestr.sh и сверяет его границы с `area_bucket()` (поточечно, на
границах и между ними); у самого CASE стоит комментарий-якорь «поменяешь на реальную
комнатность — вернись в #3256».

Каверза (e) харнеса: формулировка «бакеты 0-2 чисты» УБРАНА как неверная. Замер по
тому же пулу, который видит `_fetch_analogs` (свежесть 14 дней, вторичка, регион 66):
совпадение rooms == area_bucket — бакет 0: 69.8%, 1: 63.5%, 2: 60.1%, 3: 54.6%,
4: 30.9%. В бакетах 0-3 модальная комнатность совпадает с бакетом, в бакете 4 — нет
(мода 3, 54.5% пула). Добавлена перекрёстная ссылка: каверзы (d) и (e) СКЛАДЫВАЮТСЯ
(неправильное МЕСТО + неправильный СЕГМЕНТ), а не спорят.

Логи витрины `/street-deals` называли `rooms=%d` комнатностью клиента, хотя фильтра
по ней в запросе уже нет — теперь печатают фактический ключ (полосу площади), а
комнатность помечена как контекст запроса.

НЕ входит в этот PR (заводится отдельно): TVF `street_sales_vs_listings`
(data/sql/211_*.sql:89,113) — там асимметричный ключ (`d.rooms` синтетика,
`l.rooms` настоящая), копипастой не чинится; каверза (e) для
app/tasks/landing_showcase_deals.py:415/426.

Refs #3256
2026-09-12 02:25:09 +05:00
ae6d28d5e2 fix(mera): pool_timeout 30→5 с — отдельным коммитом, с триггером отката
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Единственная правка ветки, которая меняет РЕЖИМ ОТКАЗА при исчерпании пула:
было «медленно» (ждём коннект до 30 с), стало «быстро с ошибкой» (5 с и
`sqlalchemy.exc.TimeoutError` → 500, глобального обработчика в app/main.py нет).
И едет она во все сервисы образа — backend, scraper, tgbot
(tradein-mvp/docker-compose.prod.yml), для скраппера и бота обоснования в коде
нет: за 29 ч логов исчерпания пула не было ни разу, проверить новое значение на
проде пока не на чем.

Поэтому коммит последний в ветке: ветку можно мержить без него, а на проде —
откатить одной командой (`git revert`).

Обоснование самого значения: чекаут коннекта нельзя прервать `asyncio.wait_for`,
он занимает поток `asyncio.to_thread` целиком, а пул потоков конечен
(min(32, cpu+4)) — исчерпанный пул коннектов превращается в исчерпанный пул
потоков. 5 с короче самого короткого бюджета источника (8 с Yandex/Cian/
house_meta; geocode 12 с, IMV 20 с — длиннее): занятый пул деградирует ОДИН
источник, а не весь запрос.

ТРИГГЕР ОТКАТА (вернуть 30 с) записан в комментарии рядом со значением: любое
`QueuePool limit ... timed out` в логах бэкенда ЛИБО рост failed+zombie в
`scrape_runs` после деплоя.

Правка комментария по ревью (L2): «вчетверо больше любого бюджета внешнего
источника (8 с)» было неточно — бюджеты 8 / 12 / 20 с, перечислены явно.
Гейт `test_pool_checkout_wait_shorter_than_source_budget` переехал сюда же (в
коммите без `pool_timeout` он был бы красным) и читает публичный
`engine.pool.timeout()` вместо приватного `pool._timeout`.

Refs #3083, #3408
2026-09-12 02:23:09 +05:00
cf71825c27 fix(mera): отмена по бюджету оставляла осиротевший поток в чужой Session
Ревью PR #3444, M1. `_with_budget` — это `asyncio.wait_for`, а `asyncio.to_thread`
отменить нельзя: снимается только ожидание со стороны loop'а. Корутина умирает,
поток продолжает работать с ТОЙ ЖЕ `Session`, а вызывающий тем временем идёт
дальше по своим шагам ПО ТОЙ ЖЕ сессии — следующий источник,
`_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной сессии
дают «another operation is in progress» / InvalidRequestError на следующем шаге
БД: у источников её глушит `except` вокруг вызова, у персиста оценки не глушит
ничего — 500 и потерянная оценка клиента, ровно под нагрузкой, ради которой PR и
делается.

`_db_step` теперь пробрасывает отмену ПОСЛЕ того, как поток отпустил сессию
(`asyncio.shield` + ожидание шага). Цена — бюджет источника переезжает на длину
ОДНОГО шага БД, а не на длину фетча, ради которой бюджет заведён.

Почему не `threading.Lock` на сессию (вариант из ревью): лок внутри `_db_step`
сериализует только шаги, которые через `_db_step` и проходят, — а названный
пострадавший `_persist_estimate_and_commit` (estimator.py:5203) это ГОЛЫЙ
`asyncio.to_thread(db...)`, как и ещё 17 мест эстиматора; лока они не берут, и
дыра осталась бы открытой ровно там, где она стоит 500. Ожидание же в точке
отмены закрывает ВСЕХ последующих потребителей сессии разом и не заводит
глобального состояния (`WeakKeyDictionary`). Гейт по значению —
tests/test_3408_db_step_cancel_orphan.py: следующий шаг (голый `to_thread`, как
персист) не входит в сессию, пока сирота не закончил. Семантика проверена на
питоне прода (3.12): `wait_for` по-прежнему отдаёт TimeoutError, источник
деградирует в None.

Остальное из ревью:
- M2: комментарий у `_MAX_DEFERRED_REFRESH_TASKS` обещал за ОБА фоновых
  источника, а верен только для Яндекса. Циан держит коннект весь фетч (до 25 с):
  транзакцию открывают `_load_from_cache`/`load_session`, закрывает `db.commit()`
  в конце (scraper_kit .../cian/valuation.py:163,176,595). Формулировка сужена,
  остаток назван явно: функция общая со скраппером (cian_history_backfill.py:458),
  где коммит в середине менял бы семантику батча, — нужен отдельный опт-ин путь.
  На ПОТОЛОК пула остаток не влияет (коннект на задачу один независимо от того,
  как долго держится), только на среднюю занятость.
- L1: `db.rollback()` после упавшего `_db_step` (estimator.py:1186) удалён —
  откат уже сделан в потоке, а на loop'е это блокирующий вызов.
- L4: в core/db.py записано, что «пул >= суммы объявленных потолков» — ПОЛ, а не
  гарантия: коннект держит и любая ручка с `Depends(get_db)`, а глобального
  обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет (проверено:
  единственный handler — RequestValidationError, core/http_errors.py:59).
- L3: гейт пула больше не читает `pool._timeout` и не молчит при переименовании
  `_max_overflow` — публичный `pool.size()` + приватное поле за явным assert'ом.

`pool_timeout` из этого коммита УБРАН намеренно: это единственная правка, которая
меняет режим отказа с «медленно» на «быстро с ошибкой», и она едет во все сервисы
образа (backend, scraper, tgbot). Возвращается отдельным коммитом в конце ветки —
чтобы ветку можно было смержить без него или откатить одной командой.

Refs #3083, #3408
2026-09-12 02:20:26 +05:00
d6b43c6100 Merge pull request 'ci(#3274): гейт против возврата фронта в общую команду и снятия ретрая (часть 2b/3)' (#3447) from fix/3274-part2b-gate into main 2026-09-11 20:56:41 +00:00
bot-backend
bbdcfeb825 ci(#3274): гейт против возврата фронта в общую команду и снятия ретрая (часть 2b/3)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m16s
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 17m28s
Только scripts/ + ci.yml: ни deploy.yml, ни deploy-tradein.yml по этим путям
не триггерятся. Мержить ПОСЛЕ частей 1 и 2a — гейт проверяет обе половины
и на main без них покраснеет.
2026-09-12 01:38:16 +05:00
84920e6cbd Merge pull request 'fix(caddy): ретрай подключения к фронту МЕРЫ — остаток окна подмены (#3274, часть 2a/3)' (#3446) from fix/3274-part2a-caddy-retry into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 7s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 38s
Deploy / build-worker (push) Successful in 41s
Deploy / build-frontend (push) Successful in 39s
Deploy / deploy (push) Successful in 1m30s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m41s
2026-09-11 20:37:57 +00:00
bot-backend
5f5bfa8a83 fix(caddy): ретрай подключения к фронту МЕРЫ — остаток окна подмены (#3274, часть 2a/3)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Только caddy/sites/apps.caddy: по фильтру deploy.yml это caddy_only=true →
джоба deploy-caddy с 'caddy reload' (~1 с), без полного деплоя ПТИЦЫ.
Гейт и шаг ci.yml едут отдельной частью 2b, которая не триггерит ничего.
2026-09-12 01:22:56 +05:00
204e2e09de Merge pull request 'fix(deploy): подменять фронт МЕРЫ отдельной командой — окно простоя 30–90 с уходит (#3274, часть 1/2)' (#3442) from fix/3274-part1-deploy-swap into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Successful in 37s
Deploy Trade-In / build-frontend (push) Successful in 2m11s
Deploy Trade-In / test (push) Successful in 4m22s
Deploy Trade-In / build-backend (push) Successful in 34s
Deploy Trade-In / deploy (push) Successful in 6m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
2026-09-11 20:21:52 +00:00
09c7bb3ac7 Merge pull request 'fix(smoke): «ответа нет» ≠ «неверный код» — ретраи и честная формулировка в смоуке периметра МЕРА' (#3441) from fix/perimeter-smoke-retry-on-no-response into main
All checks were successful
perimeter-smoke-mera / smoke (push) Successful in 1m40s
2026-09-11 20:16:27 +00:00
134985a624 fix(smoke): отказ TLS-сертификата — это FAIL, а не «ответа нет»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Повтор запроса включался на ЛЮБОМ ненулевом rc curl, хотя собственный
комментарий рядом называл сетевой класс (6/28/35/52/56). Протухший, чужой
или самоподписанный сертификат даёт rc=60 (замер: expired.badssl.com,
self-signed.badssl.com, wrong.host.badssl.com) — и измеренный регресс
периметра уезжал в колонку «периметр этой проверкой НЕ проверен», потратив
на детерминированный отказ три попытки и 6 c пауз. Ровно этот отказ и есть
предмет проверки 5b: без site-блока Caddy не выпускает сертификат.

Коды сетевого класса вынесены в NETWORK_RC рядом с комментарием, чтобы
описание и поведение не разъезжались; повтор делается только по ним. rc=7
(соединение отвергнуто) добавлен туда же — ответа при нём тоже нет.
curl_failed печатает rc в обеих ветках: строки RETRY при SMOKE_ATTEMPTS=1
нет вовсе, и «домена нет» (6) было не отличить от «сертификат протух» (60).

timeout-minutes 10 → 40: худший случай (прод не отвечает — мертвы все 43
проверки) = 43 × 53 c ≈ 38 мин, в 10 минут помещалось ~8 мёртвых проверок,
и job убивали ДО печати FAIL-строк и итога — в том самом сценарии, ради
которого правка и делалась.
2026-09-12 01:07:11 +05:00
a780e3e66e fix(estimator): ключевать сделки Росреестра area-бакетом, а не комнатностью клиента
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
`deals.rooms` — не комнатность, а синтетика из площади: import-rosreestr.sh пишет
тот же CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`. Прод-замер
2026-09-11: 321 559 из 321 560 сделок удовлетворяют rooms == area_bucket(area_m2),
max(rooms) = 4. Значит предикат `deals.rooms = <РЕАЛЬНЫЕ комнаты клиента>` — это
переодетый фильтр по площади, который противоречит area-полосе ±15% рядом с ним,
как только комнатность клиента нетипична для метража, и НИКОГДА не совпадает у
клиентов с 5+ комнатами.

Замер по 1177 реальным запросам (trade_in_estimates): ключ расходился с
area-бакетом у 359 (30.5%); коридор ДКП пуст у 46.2% из них против 8.2% у
совпадающих. По крупному жилью (≥85 м²): «3 комнаты» — 83.5% пустых коридоров,
«5 комнат» и «6 комнат» — 100%, «4 комнаты» — 5%. Т.е. блок «реальные сделки»
и клампы коридора (cap headline + radius-floor) молча выключались ровно у
крупных лотов.

Прогон тех же 1177 запросов через `_fetch_dkp_corridor` с обоими ключами:
непустых коридоров 809 → 895, пригодных для клампа (n≥10) 567 → 623 (+66, −10),
у 818 клиентов с совпадающей комнатностью выборка не меняется вовсе. Из 66
восстановленных коридоров 7 (5 из них ≥85 м²) обрезали бы headline вниз на
медианных −10.1% — то есть сейчас часть крупных лотов оценивается выше, чем
поддерживают реальные ДКП на той же улице.

Правка — одно и то же во всех четырёх местах, где сделки фильтруются под
клиента: `_fetch_dkp_corridor` (street + city-wide widen), `_fetch_deals`
(радиус) и витрина `/street-deals`.

Бэктест этим НЕ измеряется и в докстринг харнеса добавлена причина (каверза (e)):
у всех 5500 сделок обеих прод-фикстур rooms == area_bucket, т.е. харнес кормит
спайн синтетическим ключом и поэтому по построению не видит расхождения, которое
в проде есть у 30.5% запросов.

Refs #3256
2026-09-12 01:04:05 +05:00
9f696299de fix(mera): sync-БД источников эстиматора — с event loop в поток и не через фетч
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
Замер на проде 11.09 (изнутри хоста, тот же контейнер):
- одна оценка 0.44 с (повтор адреса) / 0.97 с (новый адрес), из них БД 252/458 мс;
- N=8 параллельных — все 200, heartbeat /health p95 5-7 мс, max 113-160 мс:
  loop сегодня НЕ голодает, «добавить воркеров uvicorn» замером не подтверждается
  (и умножило бы на N оба семафора, пять in-process лимитеров и пул);
- зато одна фоновая догрузка Яндекса держала коннект пула 8.5 с (лиз прокси
  33.856 → запись 42.334), а таких задач разрешено 8 при пуле 15.

Правки:
- estimator `_db_step`: SELECT/UPSERT кэша источников уходят в `asyncio.to_thread`
  и завершают транзакцию — коннект возвращается в пул ДО внешнего HTTP;
- core/db: max_overflow 10→15 (потолок 20 на процесс ≥ 4+4+8 объявленных
  потолков одновременности) и pool_timeout 30→5 с (короче бюджета источника 8 с,
  иначе занятый пул съедает и бюджет запроса, и поток to_thread).

Локальный замер ДО/ПОСЛЕ на тех же величинах: loop стоял 301 мс (0 тиков соседней
корутины) → 0.2 мс (23.5k тиков); ожидание коннекта соседом во время фетча —
таймаут пула → 0.1 мс.

Refs #3083, #3408
2026-09-12 01:01:54 +05:00
dacd298b21 fix(deploy): подменять фронт МЕРЫ отдельной командой — окно 30–90 с уходит
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m0s
CI / backend-tests (pull_request) Successful in 17m25s
Публичный лендинг meraocenka.ru лежал 30–90 с на КАЖДОМ деплое (#3274).
Причина не в скорости подмены контейнера: она стоит полсекунды.

`docker compose up -d` со СПИСКОМ сервисов работает в две фазы — сначала
create (старый контейнер каждого сервиса останавливается и УДАЛЯЕТСЯ, иначе
занято container_name), потом start, в порядке зависимостей и с ожиданием
их условий. Между фазами старого фронта уже нет, а новый ещё не запущен.

Прод, 10.09, два деплоя подряд (docker inspect .Created/.StartedAt):
  пачка сервисов:  tradein-backend  создан 15:01:40 → запущен 15:02:10 (30 с),
                   в логе Caddy три 503 на лендинге: 15:01:46/:52 и 15:02:06;
  ОДИН сервис:     tradein-frontend создан 16:42:17.5 → запущен 16:42:18.0
                   (0,5 с), 503 в логе нет ни одного.

Тот же двухфазный порядок воспроизведён на стенде (реальный образ фронта +
Caddy 2): соседи создаются сразу, стартуют через 41 с.

Поэтому frontend убран из общего `up -d $SERVICES` и пересоздаётся своей
командой после пачки: в его графе один сервис, create и start идут подряд.
Остаток ~0,5 с добирает ретрай подключения в Caddy — отдельным коммитом,
он мержится своим путём (caddy_only → graceful reload, без пересборки).

Проба для замера на живом деплое — scripts/probe-deploy-window.sh: считает
коды и САМУЮ ДЛИННУЮ серию не-200 в секундах, 000 отдельной строкой (его
даёт и отбой периметра, не только простой). Запускать НА хосте прода:
с внешнего адреса частая серия сама ловит 30–50 % 000.

Refs #3274
2026-09-12 00:44:46 +05:00
c0e45b48d3 fix(smoke): отличать «ответа не было» от «код не тот» в смоуке периметра
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Прогон perimeter-smoke-mera на голове main (a659b187) покраснел на двух
последних проверках с кодом `000`. `000` у curl — это не «пришёл неверный
код», а «ответа не было вовсе»: периметр был цел, те же пути вручную
отдавали 503 и 405 от приложения (server: uvicorn, не заглушка Caddy).

Замер причины (внешний IP, /trade-in/api/v1/me): серия без пауз — 5 обрывов
из 12, с паузой 0.5 c — 6 из 12, с паузой 2 c — 0 из 8; с самого хоста прода
— 0 из 10. Приложение отвечает, частую серию запросов с одного адреса
отбивает вход. Смоук шлёт 44 запроса подряд и попадает под тот же эффект,
поэтому падают последние проверки списка.

Что сделано (ожидания и список путей НЕ тронуты):
- один общий curl_try, через который идут все запросы смоука. Повтор
  только при «ответа не было» — признак берётся у самого curl (ненулевой
  код возврата), ответ с «не тем» кодом для curl успешен и не повторяется
  никогда, иначе ретрай маскировал бы настоящий регресс;
- отдельная формулировка FAIL (ответа нет) + итоговая строка «БЕЗ ОТВЕТА:
  N проверок» — чтобы читатель красного лога не искал регресс периметра
  там, где измерения не было;
- пауза 2 c между проверками. Наименьшая величина, у которой есть замер:
  0.5 c измеренно не помогает, промежуточные значения не мерил никто;
- timeout-minutes воркфлоу 5 → 10: обычный прогон 89 c → 160 c (замер), а
  неотвечающая проверка стоит до 3×15 c таймаута плюс паузы, и job убивали
  бы до печати FAIL-строк.

Побочно тем же сторожем закрыты места, где обрыв врал диагнозом: в
check_redirect_location он читался как «Location не тот», а обрыв на
загрузке лэндинга — как «сам лэндинг сломан».

Проверка правки: прогон до (89 c, 43/43 PASS) и после (160 c, 43/43 PASS);
подставной curl, роняющий каждый нечётный запрос, — 44 повтора, итог
зелёный; фальсификация с подменённым ожиданием (/me → 200) и неверным URL
(/oferta-net-takogo) — обе строки красные, повторов ноль, выход 1.
2026-09-12 00:28:55 +05:00
a659b18771 Merge pull request 'МЕРА B2C: микроразметка schema.org и картинки превью ссылок' (#3438) from feat/mera-schema-og into main
Some checks failed
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / deploy-caddy (push) Has been skipped
Deploy / changes (push) Successful in 10s
Deploy Trade-In / changes (push) Successful in 14s
perimeter-smoke-mera / smoke (push) Failing after 15s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 38s
Deploy / build-backend (push) Successful in 40s
Deploy / build-worker (push) Successful in 41s
Deploy / deploy (push) Successful in 1m13s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Failing after 12s
Deploy Trade-In / build-frontend (push) Successful in 2m11s
Deploy Trade-In / deploy (push) Successful in 1m0s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-10 16:38:50 +00:00
bot-backend
a82382f47a feat(mera-b2c): микроразметка schema.org и картинки превью для публичного сайта
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
Сайт открыт для индексации 10.09.2026, но поисковику он до сих пор
представлялся девятью страницами без единого структурированного факта о
том, кто их публикует и что продаёт, а ссылка на него в мессенджере
разворачивалась голым текстом без картинки.

МИКРОРАЗМЕТКА. Конструкторы узлов вынесены в `_lib/schema.ts` — чистый
модуль без React, по образцу соседнего `_lib/analytics.ts`. Все значения
берутся из `content.ts`, ни одной строки не продублировано:

- `Organization` + `WebSite` одним `@graph` в layout поддерева, то есть на
  всех девяти страницах сразу. Остальные узлы ссылаются на организацию
  через `@id`, а не повторяют реквизиты у себя.
- `Service` с `Offer` на лэндинге.
- `FAQPage` в блоке возражений.
- `BreadcrumbList` на статьях, статье, документах и странице для бизнеса —
  ровно по тем крошкам, что видны на экране.
- Существующий `Article` переведён на ссылку `publisher: { "@id": ... }`.

РАЗМЕТКА НЕ ОБЕЩАЕТ ТОГО, ЧЕГО НЕТ. Два места, где это стоило внимания:

- `Offer.availability` вычисляется из `PUBLIC_ESTIMATE_ENABLED`. Флаг сейчас
  `false` (платёжного контура в коде нет), поэтому в разметку уходит
  `PreOrder`, а не `InStock`: цена опубликована, купить нельзя, и врать об
  этом поисковику нечего. Включится приём оплаты — значение сменится само.
- `FAQPage` строится из того же массива `ITEMS`, который рисует `<details>`,
  а не из `FAQ` напрямую: в `ITEMS` часть вопросов заменена макетными
  формулировками, и разметка от сырого `FAQ` разошлась бы с видимым текстом
  молча. Выдуманных дат, рейтингов и отзывов не добавлено нигде.

КАРТИНКИ. Две штуки, и это не дубль: `og-mera.png` 1200×630 — превью ссылки,
`logo-mera.png` 512×512 — логотип организации в разметке, который поисковик
обрезает близко к квадрату и где баннер превратился бы в обрезок надписи.
Исходники обеих лежат рядом в `scripts/og/*.html` вместе с командой
перерисовки: картинка должна оставаться правимой, а не только
переоткрываемой в графическом редакторе.

Готовые PNG, а не `opengraph-image.tsx`: satori внутри `ImageResponse` рисует
только переданными ему байтами шрифта и кириллицу по умолчанию не покрывает,
плюс не видит ни CSS Modules, ни наших `--b2c-*`. Разбор — в шапке
`scripts/og/og-mera.html`.

`images` продублирован в `twitter`: Next не переносит их из `openGraph`, когда
метаданные заданы объектом, и карточка обещала бы крупное превью без картинки.

ПЕРИМЕТР. Обе картинки лежат в `public/` и раздаются Next'ом по
basePath-корню, куда rewrite `@meraPages` не достаёт, — отсюда два отдельных
`handle` по образцу robots.txt. Оба названы в `ROOT_HANDLES_ALLOWED`, иначе
двусторонний гейт периметра покраснел бы, и это правильно: корневой `handle`
— вторая дверь в тот же периметр.

Смоук проверяет не только 200, но и Content-Type: перепутанный rewrite отдаёт
200 с HTML, обходчик мессенджера молча его отбрасывает, и по логам приложения
этого не видно — запрос туда не доходит.

Проверено: tsc, eslint, 215 тестов mera-public, изоляция B2C-дерева, caddy
validate, bash -n смоука, прод-сборка с basePath и разбор отрендеренного
HTML — JSON-LD парсится, og:image и twitter:image на месте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ
2026-09-10 19:06:35 +03:00
8273da1d7a Merge pull request 'Версия согласия ПДн отстала от новой редакции политики — деплой МЕРЫ стоял' (#3437) from fix/mera-consent-version-and-smoke into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
perimeter-smoke-mera / smoke (push) Failing after 12s
Deploy Trade-In / build-browser (push) Successful in 36s
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m23s
Deploy Trade-In / deploy (push) Successful in 6m59s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-09-10 14:49:23 +00:00
bot-backend
2220df8741 fix(mera): версия согласия ПДн отстала от новой редакции политики + смоук ждал не тот код
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m3s
ДВА ПОСЛЕДСТВИЯ #3436, обнаруженные на прогоне против прода.

1. ДЕПЛОЙ МЕРЫ БЫЛ ЗАБЛОКИРОВАН. В #3436 политика конфиденциальности получила
   раздел про cookie, то есть новую редакцию, и `PRIVACY_APPROVAL` во фронте
   стал «№ 2 от 10 сентября 2026 г.». Бэкендовая `_CONSENT_POLICY_VERSION`
   осталась на «2026-08-13», а между ними стоит гейт
   `test_consent_text_frontend_sync.py` — он и упал. Job `test` в
   deploy-tradein.yml падает → `deploy` пропускается по своему
   `needs.test.result != 'failure'` → прод остался на старом образе фронта,
   при том что Caddy обновился отдельным пайплайном. Внешне это выглядело как
   «задеплоилось наполовину»: UTM на редиректе со слэшем починился, а noindex
   и robots.txt — нет.

   Гейт сработал ровно как задуман: версия согласия обязана указывать на ту
   редакцию документа, которую человек реально видел, иначе снимок согласия
   в trade_in_leads.consent_policy_version подписан не тем документом. Правим
   версию, а не тест. Согласия, собранные до 10.09, остаются с "2026-08-13" —
   в этом и смысл хранить версию per-row.

2. СМОУК ЖДАЛ 404 ТАМ, ГДЕ ПРОД ОТВЕЧАЕТ 401. Проверка «карта сайта МЕРЫ не
   просачивается через B2B-домен» ожидала 404 от allowlist'а site-блока, но
   корень gendsgn.ru закрыт пилотным basic_auth, и гейт отвечает 401 РАНЬШЕ,
   чем запрос доходит до allowlist'а. Проверка была написана без прогона
   против прода — это честно отмечено в её же комментарии — и упала на первом
   же запуске.

   Заведён `check_any`: PASS на любом из перечисленных кодов. Здесь допустимы
   401 и 404 — оба означают проверяемое («наружу этого адреса нет»), а какой
   рубеж ответил первым, к предмету проверки отношения не имеет. Жёсткое
   ожидание к тому же сломалось бы при снятии пилотного гейта. Красная строка
   осталась там, где ей место: 200 означал бы реальную течь.

Проверено: `pytest tests/test_consent_text_frontend_sync.py` — 6 passed;
полный сьют бэкенда МЕРЫ локально 5737 passed; `bash -n` на смоуке чист;
`check_any` прогнан против живого gendsgn.ru — PASS на фактическом 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ
2026-09-10 17:43:03 +03:00
aba07b581f Merge pull request 'МЕРА B2C: Яндекс.Метрика и GA4 + открытие сайта для поисковой индексации' (#3436) from feat/mera-analytics-seo into main
Some checks failed
Deploy Infra Host / sync-infra-host (push) Successful in 6s
Deploy / changes (push) Successful in 10s
Deploy / deploy-caddy (push) Has been skipped
perimeter-smoke-mera / smoke (push) Failing after 13s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Successful in 47s
Deploy / build-frontend (push) Successful in 52s
Deploy / build-backend (push) Successful in 54s
Deploy / build-worker (push) Successful in 55s
Deploy / deploy (push) Successful in 1m14s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Failing after 14s
Deploy Trade-In / build-frontend (push) Successful in 3m17s
Deploy Trade-In / test (push) Failing after 4m30s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
2026-09-10 14:14:25 +00:00
bot-backend
671fef758e feat(mera): Метрика и GA4 на публичном контуре + открытие сайта для индексации
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI / openapi-codegen-check (pull_request) Successful in 2m6s
CI / backend-tests (pull_request) Successful in 17m40s
ЗАЧЕМ. Статьи МЕРЫ публикуются с UTM-метками, но посмотреть, приходил ли по
ним кто-нибудь, было физически нечем: веб-аналитики на публичном контуре не
было вовсе. Заодно вскрылось, что «толкнуть в выдаче» тоже нельзя — всё
дерево mera-public отдавало `robots: noindex, nofollow`.

СЧЁТЧИКИ. Яндекс.Метрика и GA4 подключаются ТОЛЬКО в `mera-public/layout.tsx`
и никогда в корневом `app/layout.tsx` — иначе счётчик уехал бы в закрытый
контур (/v2, /admin, /scrapers, /history), где анонимных посетителей нет, а
приватные маршруты сотрудников есть. Идентификаторы приходят build-time
(`NEXT_PUBLIC_YM_ID` / `NEXT_PUBLIC_GA_ID`) — канон Dockerfile'а этого
проекта: Next инлайнит NEXT_PUBLIC_* на сборке, runtime env их не подхватит.
Пустое значение = тег не рендерится вовсе, никаких `ym(undefined)`. Оба
build-arg'а прописаны в ОБОИХ блоках CI, включая retry-сборку без кеша.

Вебвизор выключен намеренно. Он пишет ввод в поля, а на `/estimate` человек
вводит адрес своей квартиры; раздел 9 политики этого не раскрывает. Включать
следует одним заходом с правкой политики и маскировкой полей — в коде рядом
записано, что именно понадобится.

ЦЕЛИ ВОРОНКИ. Десять целей: клик по CTA, начало ввода адреса, адрес выбран,
результат с разбивкой по вердикту (ok/thin/none), ошибка расчёта, ошибка
валидации, отказ подсказок, показ платного тизера. Кнопок «Проверить
квартиру» восемь штук в разных компонентах, все — обычные `<a>` через
PublicLink, поэтому вместо восьми копий onClick один делегированный
слушатель на document: девятая кнопка подключится сама. Цель «оплата
успешна» НЕ заведена — вызова checkout во фронте нет вовсе, PAYMENTS_ENABLED
выключен, страницы возврата не существует; вешать её пока не на что.

ИНДЕКСАЦИЯ. Снят noindex со всех публичных страниц, добавлены `app/robots.ts`
и `app/mera-public/sitemap.ts`, metadataBase, canonical на КОРОТКИЕ адреса,
openGraph и JSON-LD Article на главной статье. robots.txt и sitemap.xml
разведены по двум разным handle в Caddy не от хорошей жизни: у Next
robots.txt — конвенция корня app/, а sitemap живёт в сегменте маршрута, и
формы путей не совпадают.

152-ФЗ. Раздел 9 «Файлы cookie и веб-аналитика» в политике (обработчики
названы поимённо — этого требует ч. 3 ст. 6) + уведомляющий, не блокирующий
баннер. Гейт «названий площадок в публичной копии быть не должно» получил
узкое исключение ровно на аналитические словосочетания в политике; голое
«Яндекс» как площадка остаётся запрещённым и там.

ПОПУТНЫЙ БАГ (замер на живом проде 10.09.2026). `meraocenka.ru/articles/`
с UTM-метками отдавал 301 на адрес БЕЗ query — матчер @meraShortSlash
собирал цель из regex-захвата пути и терял параметры. Код ответа при этом
оставался 301, поэтому смоук проблему не видел. Мессенджеры и
автолинкификаторы дописывают слэш сами, то есть атрибуция терялась именно на
трафике по опубликованной ссылке. Починено тем же приёмом, что у соседних
матчеров; в смоук добавлена проверка буквального Location.

ГЕЙТЫ. `isPublicPath` и периметр-тест узнали про новые машинные адреса;
noindex-гейт развёрнут (падает, если флаг вернулся) и расширен на строковую
форму `robots: "noindex"`; заведена проверка, что корневых `handle` в
site-блоке не появляется без объявления — раньше эту дверь гейт не видел.

Проверено: tsc и eslint чисто, `npm run build` проходит, robots.txt и
sitemap.xml отдаются по нужным адресам, при пустых ID в HTML нет ни одного
обращения к mc.yandex.ru и googletagmanager, `caddy validate` валиден,
изоляция mera-public от B2B не нарушена. Два теста LoginPage падают и на
нетронутом дереве — не наши.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ
2026-09-10 16:52:31 +03:00
196 changed files with 17417 additions and 834 deletions

View file

@ -98,6 +98,24 @@ jobs:
python3 scripts/check-compose-ambiguous-hosts.py --selftest
python3 scripts/check-compose-ambiguous-hosts.py
- name: "Guard: подмена фронта МЕРЫ без окна недоступности (#3274)"
# Тем же шагом-соседом и по той же причине: секунды на PR, падение
# блокирует merge.
#
# ЗАЧЕМ. Публичный лендинг лежал 3090 с на КАЖДОМ деплое МЕРЫ —
# не потому, что подмена контейнера медленная (0,5 с), а потому, что
# `up -d` со списком сервисов делает create всех (старые контейнеры
# УДАЛЯЮТСЯ) и только потом start, дождавшись зависимостей. Лечение —
# две половинки в разных файлах: `frontend` вынесен из общей пачки в
# deploy-tradein.yml + ретрай подключения в caddy/sites/apps.caddy.
# Обе обратимы молча и незаметно (дописать frontend обратно в SERVICES
# «за компанию»; скопировать новый публичный путь с блока без импорта),
# а отказ виден только непрерывной пробой во время деплоя — то есть
# никогда, если её никто не запустил.
run: |
python3 scripts/check-frontend-swap-window.py --selftest
python3 scripts/check-frontend-swap-window.py
- name: "Guard: Caddyfile синтаксически валиден"
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
@ -201,6 +219,30 @@ jobs:
- '.forgejo/workflows/deploy.yml'
- '.forgejo/workflows/deploy-tradein.yml'
- '.forgejo/workflows/ci.yml'
# #3448: тот же класс, ещё раз. Гейт про исключающие `!`-шаблоны
# в paths-filter проверяет ВСЕ воркфлоу, а paths-filter живёт и
# здесь — без этой строки правка ci-tradein.yml с таким шаблоном
# не запустила бы backend-tests, то есть гейт не побежал бы ровно
# на той правке, от которой стережёт.
- '.forgejo/workflows/ci-tradein.yml'
# #3467/#3475: гейт backend/tests/ops/test_3467_prometheus_reload.py
# читает оба файла ниже. Без них правка, трогающая ТОЛЬКО
# deploy-metrics.yml (скажем, дописывающая `|| true` к шагу
# перезагрузки Prometheus), даёт backend=false — джоба
# backend-tests пропускается, гейт не исполняется, регрессия
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
- '.forgejo/workflows/deploy-metrics.yml'
- 'docker-compose.metrics.yml'
# #3443: тот же класс, третий раз. Гейт
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
# все содержательные регрессии живут в самом скрипте, а не в
# deploy.yml. PR, правящий только ops/**, без этой строки давал бы
# backend=false: джоба пропускается, гейт не исполняется, и
# «пересоздавать всегда» (окно 67 с на всех доменах) или
# «не пересоздавать никогда» (правка конфига беззвучно не доезжает)
# уезжает в main зелёным.
- 'ops/**'
frontend:
- 'frontend/**'
- '.forgejo/workflows/ci.yml'

View file

@ -90,8 +90,13 @@ jobs:
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
with:
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
@ -188,6 +193,14 @@ jobs:
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
fi
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
# должно — деплой обязан сказать, что канал не поднялся.
if [ -z "${ALERT_ACK_GLITCHTIP_SECRET:-}" ]; then
echo "::warning title=Резервный канал GlitchTip выключен::ALERT_ACK_GLITCHTIP_SECRET пуст — alert-ack отвечает 503 на /glitchtip, и при падении продуктового бэкенда его ошибки доставлять будет нечем."
fi
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
else
@ -250,6 +263,19 @@ jobs:
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
fi
# Ретранслятор Telegram Bot API (#3471, PR #3487 сломал прод: сервис
# без profiles уходил в SystemExit на пустом секрете и висел в
# Restarting). Профиль relay включаем НЕЗАВИСИМО от alerts — это
# разные каналы (один шлёт алерты боту, другой ретранслирует
# продуктовый Bot API трафик с Selectel). PROFILES — список через
# запятую, как того требует COMPOSE_PROFILES.
if [ -n "${TG_RELAY_SECRET:-}" ]; then
PROFILES="${PROFILES:+$PROFILES,}relay"
echo "Ретранслятор Telegram: секрет задан, профиль relay включён."
else
echo "::warning title=Резервный ретранслятор Telegram выключен::TG_RELAY_SECRET пуст — tg-relay не поднимается (профиль relay выключен). Продуктовый Telegram-трафик пойдёт напрямую с Selectel, где теряется примерно каждый четвёртый короткий запрос."
fi
# ── Цели file_sd для Prometheus (#3155) ────────────────────────
# Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ
# условии. Пока они жили порознь, вышло так: 27.08 профиль alerts
@ -268,19 +294,27 @@ jobs:
AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml
: > "$AM_TARGETS_FILE"
echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE"
if [ "$PROFILES" = "alerts" ]; then
# Сравнение через case, а не "=": PROFILES теперь может быть
# комбинацией через запятую ("alerts,relay") с тех пор, как #3471
# завёл независимый профиль relay — точное равенство строке
# "alerts" сломалось бы молча в тот момент, когда оба профиля
# включены разом.
case ",$PROFILES," in
*,alerts,*)
echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE"
echo " labels:" >> "$AM_TARGETS_FILE"
echo " host: infra" >> "$AM_TARGETS_FILE"
echo "Prometheus: приёмник alertmanager:9093 прописан в целях."
else
;;
*)
# Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт
# несуществующего пути docker подменяет каталогом, и Prometheus
# не стартует вовсе.
echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE"
echo "[]" >> "$AM_TARGETS_FILE"
echo "Prometheus: профиль alerts выключен — целей нет, это штатно."
fi
;;
esac
# ── read-only роль для датасорса GlitchTip ─────────────────────
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
@ -292,6 +326,43 @@ jobs:
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
# ── alert-ack / tg-relay: код монтируется с хоста ────────────────
# Тот же класс бага, что у Alertmanager (см. ниже) и Caddyfile:
# `up -d` сравнивает ОПИСАНИЕ сервиса, а не содержимое бинд-маунта.
# alert-ack и tg-relay получают код именно бинд-маунтом файла
# (./ops/metrics/{alert-ack,tg-relay}/app.py:/app/app.py:ro), а не
# сборкой образа — правка app.py оставляет уже запущенный
# контейнер работать на СТАРОМ коде в памяти интерпретатора сколько
# угодно, и `up -d` этого не видит вовсе.
#
# Пойман на проде 12.09.2026: PR #3490 (фикс alert-ack) слился,
# `git reset --hard` обновил файл на диске (grep по новому
# комментарию находил его), а gendesign-alert-ack, запущенный за
# 25 минут до этого, продолжал отвечать по старой логике —
# зелёный деплой, тихо неверное поведение. Починил только ручной
# `docker restart gendesign-alert-ack`. force-recreate здесь —
# замена этому ручному шагу.
#
# case ",$PROFILES," — пересоздаём только если профиль сервиса
# реально включён в ЭТОМ прогоне, иначе force-recreate ругается на
# несуществующий контейнер (сервис не создан вовсе).
case ",$PROFILES," in
*,alerts,*)
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
up -d --force-recreate alert-ack
echo "alert-ack: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
;;
esac
case ",$PROFILES," in
*,relay,*)
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
up -d --force-recreate tg-relay
echo "tg-relay: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
;;
esac
# ── Alertmanager: пересоздать, если конфиг перерисовали ─────────
# `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает
# описание сервиса, а содержимое бинд-маунта в это сравнение не
@ -344,6 +415,51 @@ jobs:
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
# перечитывает ────────────────────────────────────────────────
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
# docker compose сравнивает описание сервиса, а НЕ содержимое
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
# со старым конфигом сколько угодно — на проде дошло до 16 суток
# незамеченными (#3467): lastConfigTime совпадал со startTime
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
#
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
# после `git reset --hard` подхватывается без пересоздания
# контейнера. --web.enable-lifecycle уже включён в compose ради
# этого шага (см. docker-compose.metrics.yml) — просто раньше
# никто не звал сам reload.
#
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
# положить работающий Prometheus молчаливым откатом на дефолты.
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
# lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже
# если содержимое конфига не поменялось — значит сравнение
# "было/стало" надёжно ловит и несостоявшийся reload, и
# изменившиеся правила.
LAST_CONFIG_AFTER=""
for i in $(seq 1 10); do
LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
[ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break
sleep 1
done
if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then
echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)."
exit 1
fi
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
else
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
exit 1
fi
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps:
runs-on: ubuntu-latest

View file

@ -511,12 +511,24 @@ jobs:
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
# NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
# ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
# мета-теги верификации поисковых консолей. Пустая строка = скрипт
# счётчика НЕ рендерится вообще (контракт фронта, см. тот же
# Dockerfile-комментарий). Когда номера появятся — вписать
# литералом сюда И в retry-блок ниже (оба обязательны, иначе
# ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
# образа (build-time bake, не runtime-правка на проде).
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
@ -540,6 +552,10 @@ jobs:
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
@ -1121,7 +1137,31 @@ jobs:
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
# с browser/backend/frontend, отдельного graceful-drain не требует.
SERVICES="browser backend frontend tgbot"
#
# ── FRONTEND ЗДЕСЬ НЕТ. ЭТО И ЕСТЬ ЛЕЧЕНИЕ #3274 ─────────────────
# Публичный лендинг лежал 3090 с на КАЖДОМ деплое. Причина не в
# том, что подмена контейнера медленная — она занимает полсекунды.
# Причина в том, что `docker compose up -d` со СПИСКОМ сервисов
# работает в две фазы: сначала create (старый контейнер каждого
# сервиса останавливается и УДАЛЯЕТСЯ — иначе занято container_name),
# и только потом start, в порядке зависимостей и с ожиданием их
# условий. Между фазами фронта уже нет, а нового ещё нет.
#
# Замер на проде (docker inspect, 10.09, оба деплоя МЕРЫ):
# пачка сервисов: tradein-backend создан 15:01:40 → запущен
# 15:02:10 = 30 с (и 503 на лендинге в
# 15:01:46/15:01:52/15:02:06 — ровно окно);
# ОДИН сервис: tradein-frontend создан 16:42:17.5 → запущен
# 16:42:18.0 = 0,5 с, 503 в логе нет вообще.
# Тот же двухфазный порядок воспроизведён на стенде по меткам
# .Created/.StartedAt: соседи создаются сразу, стартуют через 41 с.
#
# Поэтому фронт пересоздаётся ОТДЕЛЬНОЙ командой ниже, после этой
# пачки: в его графе один сервис, фазы create и start идут подряд.
# Остаток (~0,5 с) добирает ретрай подключения в Caddy — см.
# снипет (tradein_frontend_retry) в caddy/sites/apps.caddy.
# Гейт на обе половины: scripts/check-frontend-swap-window.py.
SERVICES="browser backend tgbot"
SCRAPER_STOP_TS=""
scraper_stale=""
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
@ -1217,6 +1257,21 @@ jobs:
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
fi
# (4b) Фронт — ОТДЕЛЬНОЙ командой, один сервис в графе (#3274).
# Обоснование и прод-замеры — у SERVICES выше. Здесь важен ПОРЯДОК:
# эта команда идёт ПОСЛЕ пачки (backend уже поднят — новый SSR сразу
# ходит в новый бэкенд) и ДО `caddy reload` ниже, чтобы reload, как
# и раньше, оставался последним касанием прокси.
#
# ЗАЧЕМ ЖДАТЬ ПОСЛЕ КОМАНДЫ. `up -d` возвращает управление, когда
# контейнер ЗАПУЩЕН, а не когда Next начал слушать (на проде между
# ними ~0,1 с, см. journald «Ready in 110ms», но это не гарантия).
# Полноценная проверка фронта — health-check ниже по файлу, он же
# валит деплой при неудаче; здесь только короткая пауза, чтобы
# ретрай Caddy (2 с) не пришёлся на ещё не слушающий порт.
docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps frontend
sleep 1
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с

View file

@ -121,37 +121,98 @@ jobs:
infra: ${{ steps.filter.outputs.infra }}
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
# включает и compose, и сам workflow, где полный деплой обязателен.
# `github.event_name == 'push'` первым множителем НАМЕРЕННО: на
# workflow_dispatch у paths-filter нет диффа, и любой его ответ не должен
# уметь отключить сборку — ручной прогон обязан оставаться полным.
caddy_only: ${{ github.event_name == 'push' && steps.filter.outputs.caddy == 'true' && steps.filter.outputs.non_caddy == 'false' }}
caddy_only: ${{ steps.filter.outputs.caddy_only }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
# ── #3448: список изменённых файлов считаем САМИ ─────────────────────────
#
# ЧТО БЫЛО. Быстрый путь «правка только прокси» (#2916) не отработал НИ
# РАЗУ. Причина — НЕ пустой `event.before`: эта гипотеза опровергнута
# логом задачи 29244 (run 10881, мерж 84920e6c) — `before` там валиден,
# 204e2e09…, и `git diff` вернул ровно один файл. Причина в семантике
# самого фильтра: dorny/paths-filter склеивает шаблоны ОДНОГО фильтра
# через `some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`,
# predicate-quantifier по умолчанию `some`). Список
# non_caddy: ['**', '!Caddyfile', '!caddy/**']
# читается не как «всё, КРОМЕ caddy», а как «подходит под `**` ИЛИ не
# Caddyfile ИЛИ не caddy/**». `**` матчит всё, поэтому non_caddy был true
# ВСЕГДА и caddy_only — false всегда. В логе это видно дословно:
# ##[group]Filter non_caddy = true
# Matching files:
# caddy/sites/apps.caddy [modified]
# Исключённый файл сам себя и «исключил». deploy-caddy при этом
# пропускался, а Forgejo рисует пропущенную джобу зелёной — сигнала не
# было ни одного.
#
# ПОЧЕМУ ШЕЛЛ, А НЕ ЗАПЛАТКА К ФИЛЬТРАМ. Разность множеств тут нужна одна
# («все изменения лежат под caddy»), и выражать её действием, у которого
# ИЛИ по умолчанию, — значит снова повесить решение на незаметное
# умолчание: `predicate-quantifier: every` действует на ВЕСЬ блок и
# сломал бы backend/frontend/infra. Плюс два требования #3448: решение
# обязано быть ВИДНО в логе (иначе «сработало» и «просто не совпало»
# неотличимы), и оно не должно молча зависеть от того, что платформа
# кладёт в `before`.
#
# FAIL-SAFE. База не разрешилась (ручной запуск, пустой/нулевой `before`,
# коммита нет на сервере) → считаем изменённым ВЕСЬ репозиторий: лишний
# полный деплой безопаснее пропущенного. Фолбэка на `HEAD^..HEAD` тут
# намеренно нет: у мерж-коммита он дал бы верный ответ, а у push'а из
# нескольких коммитов — молча урезанный, и быстрый путь включился бы
# там, где приехал бэкенд.
- name: Определить изменённые файлы (#3448)
id: filter
with:
filters: |
backend:
- 'backend/**'
- 'data/sql/**'
frontend:
- 'frontend/**'
infra:
- 'docker-compose.prod.yml'
- 'Caddyfile'
- 'caddy/**'
- '.forgejo/workflows/deploy.yml'
# Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy`
# мало: он true и когда вместе с конфигом приехал бэкенд — тогда
# нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное,
# и быстрый путь включается лишь когда он false.
caddy:
- 'Caddyfile'
- 'caddy/**'
non_caddy:
- '**'
- '!Caddyfile'
- '!caddy/**'
env:
BEFORE: ${{ github.event.before }}
EVENT: ${{ github.event_name }}
run: |
set -eu
NULL_SHA=0000000000000000000000000000000000000000
BASE=""
if [ "$EVENT" = "push" ] && [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "$NULL_SHA" ]; then
git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \
|| git fetch --depth=1 --no-tags origin "$BEFORE" >/dev/null 2>&1 \
|| true
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
BASE="$BEFORE"
else
echo "::warning::коммит $BEFORE недоступен в клоне — деплой будет полным"
fi
fi
if [ -n "$BASE" ]; then
FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD)
N=$(printf '%s\n' "$FILES" | grep -c . || true)
echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N"
printf '%s\n' "$FILES" | sed 's/^/ /'
else
FILES=$(git -c core.quotePath=false ls-files)
N=$(printf '%s\n' "$FILES" | grep -c . || true)
echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный"
fi
# Те же наборы путей, что были в фильтрах до #3448.
CADDY_RE='^(Caddyfile$|caddy/)'
has() { printf '%s\n' "$FILES" | grep -qE "$1"; }
backend=false; frontend=false; infra=false; caddy_only=false
has '^(backend/|data/sql/)' && backend=true
has '^frontend/' && frontend=true
has '^(docker-compose\.prod\.yml$|Caddyfile$|caddy/|\.forgejo/workflows/deploy\.yml$)' && infra=true
# Быстрый путь: изменения ЕСТЬ и НИ ОДНО из них не лежит вне caddy.
# Проверка `N -gt 0` обязательна: пустой список иначе прошёл бы как
# «всё под caddy» и отключил бы сборку на ровном месте.
if [ "$N" -gt 0 ] && ! printf '%s\n' "$FILES" | grep -vE "$CADDY_RE" | grep -q .; then
caddy_only=true
fi
echo "Флаги: backend=$backend frontend=$frontend infra=$infra caddy_only=$caddy_only"
{
echo "backend=$backend"
echo "frontend=$frontend"
echo "infra=$infra"
echo "caddy_only=$caddy_only"
} >> "$GITHUB_OUTPUT"
build-backend:
runs-on: ubuntu-latest
@ -997,12 +1058,17 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps $WORKER_SERVICES
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile
# И в особенности новые volume mounts из docker-compose.prod.yml
# (`reload` не пересоздаёт container, поэтому новые binds не появляются —
# был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix).
docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps caddy
# Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443).
# Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` —
# то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий
# 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с).
# Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не
# появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт
# контейнер сам, как только меняется описание сервиса или образ.
# Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка
# пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и
# `caddy validate` до применения.
sh ops/caddy-apply.sh
# Forwarder: force-recreate чтобы новый image / новые env подхватывались.
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
@ -1229,14 +1295,13 @@ jobs:
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
# страница = новая строка allowlist'а.
#
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
# принять битый конфиг и оставил бы работать старый — на общем деплое это
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
# код → job краснеет, а домены продолжают обслуживаться старым конфигом.
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
# и роняет ВСЕ домены сразу.
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на
# пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а
# `caddy reload` её просто не принимает: job краснеет, домены продолжают
# обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на
# полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет
# конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе
# не доедет (пофайловый bind-маунт держит инод).
#
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
# вторая, уже против боевого файла после `git reset`.
@ -1282,14 +1347,73 @@ jobs:
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
script: |
set -euo pipefail
# #3448: ТОТ ЖЕ ЛОК, что берёт полный деплой (см. job `deploy` выше).
# Эта джоба делает `git reset --hard` в /opt/gendesign, то есть правит
# прод-дерево — ровно то, что полный деплой сериализует локом. Пока
# быстрый путь был мёртв, столкнуться было нечему; теперь есть.
exec 9>/var/lock/gendesign-docker-deploy.lock
if flock -n 9; then
echo "→ докер-лок свободен, взят сразу"
else
echo "→ докер-лок занят соседним деплоем, жду (до 900с)…"
lock_wait_started=$(date +%s)
if ! flock -w 900 9; then
echo "ERROR: не дождался лока докер-деплоя за 900с."
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
exit 1
fi
echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания"
fi
cd /opt/gendesign
git fetch origin main
# ── #3448: быстрый путь законен, только если прод отстаёт РОВНО на
# конфиг прокси ────────────────────────────────────────────────────
#
# Джоба `changes` считает дифф between-push (before→HEAD) и не знает,
# что доехало до прода. Пока caddy_only был мёртв, любой push шёл
# полным деплоем и гард свежести :latest (#2950, job `deploy`)
# прикрывал прод по умолчанию. Оживший быстрый путь этот гард
# обходит: при caddy_only=true джоба `deploy` пропускается целиком.
#
# Сценарий отказа: push A правит бэкенд, билды ~6 мин, `deploy` в
# очереди; через 2 мин push B правит только caddy/. Forgejo на
# 10.0.3 отменяет ещё не стартовавший `deploy` предыдущего прогона
# ДАЖЕ при cancel-in-progress: false (наблюдение 21.08.2026 10:35:13,
# см. шапку scripts/check-latest-image-revision.sh). Дифф A..B — один
# caddy-файл, быстрый путь включается, `compose pull` + `up -d` не
# делает никто: прод крутит старый образ при зелёной голове main.
#
# Единственный источник правды о том, что реально на проде, — HEAD
# прод-дерева (у Trade-In для этого заведён отдельный маркер
# /opt/gendesign/.tradein-deployed-sha, см. deploy-tradein.yml:150;
# у ПТИЦЫ маркера нет, но git reset ниже делает HEAD эквивалентом).
# Проверка стоит ДО reset намеренно: при отказе прод-HEAD остаётся
# честным для следующего прогона.
#
# ЧЕГО ЭТА ПРОВЕРКА НЕ ЛОВИТ: `deploy` прогона A, упавшую ПОСЛЕ
# `git reset --hard` (например на миграции). Тогда прод-HEAD уже
# равен A, а контейнеры старые, и caddy-only push пройдёт быстрым
# путём. Это остаётся за настоящим маркером «что задеплоено».
PROD_HEAD=$(git rev-parse HEAD)
OUTSIDE=$(git -c core.quotePath=false diff --name-only "$PROD_HEAD" origin/main | grep -vE '^(Caddyfile$|caddy/)' || true)
if [ -n "$OUTSIDE" ]; then
echo "::error::прод отстаёт не только по конфигу прокси — быстрый путь запрещён:"
printf '%s\n' "$OUTSIDE" | sed 's/^/ /'
echo "Запусти полный деплой через workflow_dispatch."
exit 1
fi
git reset --hard origin/main
# Конфиг примонтирован read-only с хоста, пересборка не нужна —
# контейнер читает тот же файл, что только что обновил git.
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен без пересборки и без миграций"
# #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy
# reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**,
# caddy/local/**). Caddyfile и четыре сниппета смонтированы
# ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер
# остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
# по старому. Скрипт сверяет, что именно видит контейнер, и
# пересоздаёт его только в этом случае.
sh ops/caddy-apply.sh
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
#

View file

@ -35,7 +35,35 @@ concurrency:
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 5
# 11.09.2026: было 5 минут — теперь мало. В скрипте появились пауза между
# проверками (2 c) и повтор запроса, если ответа не пришло вовсе: обычный
# прогон вырос с ~89 c до ~175 c, а ХУДШИЙ случай — гораздо больше, потому
# что каждая неотвечающая проверка стоит до 3×15 c таймаута плюс паузы
# (~53 c против обычных ~2 c).
#
# 12.09.2026: 10 минут — тоже мало, и мало ровно в том сценарии, ради
# которого повтор писался. АРИФМЕТИКА ХУДШЕГО СЛУЧАЯ. Одна неотвечающая
# проверка сетевого класса = 3×15 c таймаута + 2 c и 4 c пауз ретрая + 2 c
# паузы между проверками = 53 c. Прод не отвечает целиком (DNS не
# резолвится, вход лежит) — мертвы ВСЕ проверки: 43 × 53 = 2279 c ≈ 38 мин.
# Откуда 43 (замер 12.09, зелёный прогон против прода — 43 PASS за 167 c):
# 42 обычные проверки + отдельная загрузка HTML лэндинга; 43-я, производный
# layout-чанк, при мёртвом ответе не запрашивается вовсе — запросов ровно
# столько же.
# В 10 минут помещалось ~8 мёртвых проверок из 43, дальше job убивали ДО
# печати FAIL-строк и итога — то есть лог терялся при полном отказе прода.
#
# Правка «повторяем только сетевой класс» (12.09) худший случай НЕ
# уменьшает: 15-секундный таймаут как раз сетевой (rc=28) и повторяется
# по-прежнему. Она удешевляет ДРУГОЙ сценарий — протухший/чужой сертификат
# (rc=60): отказ приходит сразу и без повторов. Замер 12.09 на
# expired.badssl.com, одна проверка при SMOKE_PAUSE=0 — 23 c на прежней
# голове (3 попытки + 6 c пауз) против <1 c теперь.
#
# 40 минут = 38 мин худшего случая + запас на чекаут и разброс сети.
# Цена промаха несимметрична: занятый раннер стоит дёшево (прогон daily +
# on-push), потерянный лог при полном отказе прода — дорого.
timeout-minutes: 40
steps:
- name: Checkout repo

View file

@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.observability.metrics import REPORTS_EXPORTED
from app.schemas.parcel import (
AnalysisRunDetail,
AnalysisRunListResponse,
@ -1612,6 +1613,11 @@ def export_parcel_forecast(
if run is None:
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
# #3471: считаем выгрузку здесь, а не в каждой format-ветке ниже — рано
# (до самого рендера), зато один раз на весь запрос и без риска разъехаться
# с новой веткой формата, если её когда-нибудь добавят.
REPORTS_EXPORTED.labels(format=format).inc()
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
if format == "tg":
return Response(
@ -4911,6 +4917,7 @@ async def get_parcel_best_layouts_pdf(
today = _dt.date.today().strftime("%Y-%m-%d")
cad_safe = cad_num.replace(":", "-")
filename = f"tz-layout-{cad_safe}-{today}.pdf"
REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
return Response(
content=pdf_bytes,
media_type="application/pdf",

View file

@ -100,6 +100,19 @@ BUILD_INFO.labels(
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
).set(1)
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
#
# `format` — фиксированный литерал из сигнатуры эндпоинта (Literal["md", "json",
# "tg", "docx", "pptx", "pdf"] в `export_parcel_forecast` + одно статичное
# значение "best_layouts_pdf" из ТЗ-на-проектирование), НЕ произвольная строка —
# кардинальность ограничена набором форматов экспорта, а не количеством
# участков/пользователей.
REPORTS_EXPORTED = Counter(
"sitefinder_reports_exported_total",
"Экспортов отчётов по участку (§22-форсайт, ТЗ на проектирование), по формату",
labelnames=("format",),
)
def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.

View file

@ -17,12 +17,22 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
per-test ``patch`` он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
что их ожидаемые значения резолвера сохраняются.
Perf-fix (2026-09-12): в конце ``analyze_parcel`` безусловный best-effort
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
В песочнице тестов Celery-брокер (Redis) недоступен ``.delay()`` синхронно ждёт
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
тестов каталога (как и с резолвером выше) тесты самого enqueue
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
"""
from __future__ import annotations
from collections.abc import Iterator
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@ -37,3 +47,34 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
"""
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
yield
@pytest.fixture(autouse=True)
def _stub_forecast_enqueue() -> Iterator[None]:
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
``.delay(...)`` в проде fire-and-forget (best-effort, обёрнут в try/except в
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
"""
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
yield
@pytest.fixture(autouse=True)
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
значение). В тестах фетч замокан и геометрия не появится никогда каждый такой
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
``test_recent_permits_invalid_cad_no_regression``).
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
``test_run_history_and_response_contract.py``) переопределяют это поверх.
"""
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)

View file

@ -0,0 +1,463 @@
"""Полный деплой не пересоздаёт Caddy без нужды (#3443).
ЧТО СЛУЧИЛОСЬ. Каждый полный деплой ПТИЦЫ делал `up -d --force-recreate
--no-deps caddy`, то есть сносил единственный процесс, слушающий 80/443.
Замер 05.09 (#3274): 67 с `code=000` на ВСЕХ доменах хоста — gendsgn.ru,
meraocenka.ru и зеркала. Не 502/503: принимающего процесса нет вовсе, поэтому
заглушка окна деплоя бессильна по построению её отдаёт тот же Caddy.
ЧТО УСТАНОВЛЕНО. Безусловный флаг появился 17.05 (11e78d73) ради нового
bind-маунта `./preview`, который «не появлялся в running container». Довод
неверен: `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер
сам, как только меняется описание сервиса или образ (проверено на живом демоне
docker 28.4). Единственное, чего compose не видит, СОДЕРЖИМОЕ пофайлового
bind-маунта: `git reset --hard` пишет новый инод, контейнер держит прежний, и
`caddy reload` перечитывает старый текст. У Caddy так смонтированы Caddyfile и
четыре сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают.
ЗАЧЕМ ЭТОТ ФАЙЛ. У правки нет отрицательного признака: вернуть `--force-recreate`
«на всякий случай» одна строка, все деплои останутся зелёными, а окно в минуту
увидит только тот, кто в этот момент держал непрерывную пробу. Проверки ниже
ИСПОЛНЯЮТ ops/caddy-apply.sh с подставным `docker` и смотрят на СОВЕРШЁННЫЕ
действия (пересоздал / перезагрузил / не тронул), а не на текст скрипта.
Отдельно проверяется проводка в deploy.yml что оба пути деплоя зовут именно
его.
"""
from __future__ import annotations
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "caddy-apply.sh"
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
DEPLOY = WORKFLOWS / "deploy.yml"
CID = "caddy-cid-0001"
# Маунты Caddy ровно как на проде (`docker inspect gendesign-caddy-1`, 12.09):
# пять ПОФАЙЛОВЫХ bind-маунтов и три каталога. Тома (caddy_data и соседи) в
# сверку не входят — их фильтрует `{{if eq .Type "bind"}}`.
FILE_MOUNTS = {
"Caddyfile": "/etc/caddy/Caddyfile",
"caddy/users.caddy.snippet": "/etc/caddy/caddy/users.caddy.snippet",
"caddy/metrics-ui.caddy.snippet": "/etc/caddy/caddy/metrics-ui.caddy.snippet",
"caddy/metrics-ingest.caddy.snippet": "/etc/caddy/caddy/metrics-ingest.caddy.snippet",
"caddy/deploy-window.caddy.snippet": "/etc/caddy/caddy/deploy-window.caddy.snippet",
}
DIR_MOUNTS = {
"caddy/sites": "/etc/caddy/caddy/sites",
"caddy/local": "/etc/caddy/caddy/local",
"preview": "/srv/preview",
}
# Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию:
# run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC;
# inspect — список маунтов из $FAKE_MOUNTS, код из $FAKE_INSPECT_RC;
# exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/<slug>);
# compose … ps — текущий id контейнера из $FAKE_CID_FILE;
# compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам).
FAKE_DOCKER = r"""#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
cmd="$1"; shift
case "$cmd" in
run) exit "${FAKE_VALIDATE_RC:-0}" ;;
inspect)
if [ "${FAKE_INSPECT_RC:-0}" != "0" ]; then
echo "Error: No such object" >&2
exit "$FAKE_INSPECT_RC"
fi
cat "$FAKE_MOUNTS"
;;
exec)
dst="$3"
view="$FAKE_VIEW/$(printf '%s' "$dst" | tr '/' '_')"
[ -f "$view" ] || exit 1
sha256sum "$view"
;;
compose)
case " $* " in
*" ps "*) cat "$FAKE_CID_FILE" ;;
*--force-recreate*) echo "recreated caddy (forced)" ;;
*" up "*)
if [ "${FAKE_UP_RECREATES:-0}" = "1" ]; then
printf 'caddy-cid-NEW\n' > "$FAKE_CID_FILE"
echo "Container gendesign-caddy-1 Started"
else
echo "Container gendesign-caddy-1 Running"
fi
;;
*) echo "(compose $*)" ;;
esac
;;
esac
exit 0
"""
# macOS несёт shasum вместо sha256sum; на раннере (ubuntu) и на проде утилита
# настоящая. Шим ставится только при её отсутствии — иначе гейт не запускался бы
# локально вовсе.
SHA_SHIM = '#!/bin/sh\nexec shasum -a 256 "$@"\n'
def _write_exec(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@pytest.fixture
def prod_tree(tmp_path: Path) -> Path:
"""Копия боевого дерева: скрипт + конфиги + «взгляд контейнера»."""
tree = tmp_path / "opt" / "gendesign"
(tree / "ops").mkdir(parents=True)
shutil.copy(SCRIPT, tree / "ops" / SCRIPT.name)
for rel in [*FILE_MOUNTS, "caddy/sites/apps.caddy", "caddy/local/.gitignore"]:
path = tree / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# {rel} версия НОВАЯ\n", encoding="utf-8")
(tree / "preview").mkdir()
view = tmp_path / "view"
view.mkdir()
# По умолчанию контейнер видит ровно то же, что лежит на диске.
for rel, dst in FILE_MOUNTS.items():
(view / dst.replace("/", "_")).write_text(
(tree / rel).read_text(encoding="utf-8"), encoding="utf-8"
)
mounts = tmp_path / "mounts"
mounts.write_text(
"".join(f"{tree / rel}|{dst}\n" for rel, dst in {**FILE_MOUNTS, **DIR_MOUNTS}.items()),
encoding="utf-8",
)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_write_exec(bin_dir / "docker", FAKE_DOCKER)
if shutil.which("sha256sum") is None:
_write_exec(bin_dir / "sha256sum", SHA_SHIM)
(tmp_path / "cid").write_text(CID + "\n", encoding="utf-8")
(tmp_path / "log").write_text("", encoding="utf-8")
return tree
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
root = tree.parent.parent
env = {
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
"FAKE_LOG": str(root / "log"),
"FAKE_CID_FILE": str(root / "cid"),
"FAKE_MOUNTS": str(root / "mounts"),
"FAKE_VIEW": str(root / "view"),
**env_extra,
}
proc = subprocess.run(
["sh", str(tree / "ops" / "caddy-apply.sh")],
cwd=str(tree),
env=env,
capture_output=True,
text=True,
)
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
return proc.returncode, proc.stdout + proc.stderr, calls
def _stale(tree: Path, dst: str) -> None:
"""Контейнер остался на старом иноде этого маунта."""
view = tree.parent.parent / "view" / dst.replace("/", "_")
view.write_text("# версия СТАРАЯ\n", encoding="utf-8")
def _recreated(calls: list[str]) -> bool:
return any("--force-recreate" in c for c in calls)
def _reloaded(calls: list[str]) -> bool:
return any("caddy reload" in c for c in calls)
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
def test_nothing_changed_reloads_without_recreate(prod_tree: Path) -> None:
"""Обычный полный деплой (конфиг прокси не трогали): reload, без окна."""
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), (
f"Caddy пересоздан, хотя ничего не изменилось: {calls}. "
"Это и есть #3443: 67 с code=000 на всех доменах при каждом деплое."
)
assert _reloaded(calls), f"конфиг не применён вовсе: {calls}"
def test_changed_file_mount_forces_recreate(prod_tree: Path) -> None:
"""Caddyfile правлен: reload перечитал бы старый инод — нужен recreate."""
_stale(prod_tree, "/etc/caddy/Caddyfile")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), (
f"пересоздания нет: {calls}. Пофайловый bind-маунт держит инод — правка "
"Caddyfile не доехала бы до контейнера, а деплой ушёл бы зелёным."
)
assert "/etc/caddy/Caddyfile" in out, f"решение не названо в логе:\n{out}"
@pytest.mark.parametrize("dst", sorted(set(FILE_MOUNTS.values()) - {"/etc/caddy/Caddyfile"}))
def test_changed_snippet_forces_recreate(prod_tree: Path, dst: str) -> None:
"""Каждый из четырёх сниппетов — тот же класс, не только Caddyfile."""
_stale(prod_tree, dst)
_, _out, calls = _run(prod_tree)
assert _recreated(calls), f"{dst}: правка сниппета не доехала бы: {calls}"
def test_directory_mount_change_does_not_recreate(prod_tree: Path) -> None:
"""caddy/sites/apps.caddy — самый частый случай; каталог инод не держит.
Если сюда приползёт пересоздание «за компанию», окно недоступности вернётся
ровно на тех правках, ради которых заведён быстрый путь #2916.
"""
(prod_tree / "caddy" / "sites" / "apps.caddy").write_text("# новый блок\n", encoding="utf-8")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), f"правка в КАТАЛОГЕ вызвала пересоздание: {calls}"
assert _reloaded(calls), f"правка в каталоге не применена: {calls}"
def test_broken_config_touches_nothing(prod_tree: Path) -> None:
"""Битый конфиг: ни up, ни reload, ни пересоздания — прокси не тронут.
Иначе опечатка в Caddyfile уводит контейнер в crash-loop и роняет все
домены сразу (ровно то, чем опасен `--force-recreate` вслепую).
"""
rc, out, calls = _run(prod_tree, FAKE_VALIDATE_RC="1")
assert rc != 0, f"скрипт не упал на битом конфиге:\n{out}"
assert not _recreated(calls), f"битый конфиг поехал в пересоздание: {calls}"
assert not _reloaded(calls), f"битый конфиг поехал в reload: {calls}"
assert not any(" up " in f" {c} " for c in calls), f"был `up` при битом конфиге: {calls}"
def test_compose_recreate_is_not_doubled(prod_tree: Path) -> None:
"""compose пересоздал сам (сменилось описание сервиса/образ) — хватит.
Второй `--force-recreate` поверх ещё одно окно недоступности на ровном
месте, а новый контейнер и так читает свежие файлы.
"""
rc, out, calls = _run(prod_tree, FAKE_UP_RECREATES="1")
assert rc == 0, out
assert not _recreated(calls), f"пересоздание сделано дважды: {calls}"
assert not _reloaded(calls), f"reload поверх свежего контейнера: {calls}"
def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> None:
"""Сверка не отработала (контейнер не отвечает) → прежнее поведение.
Fail-safe направлен в сторону пересоздания: лучше окно в секунды, чем
беззвучно не применённая правка конфига прокси.
"""
(prod_tree.parent.parent / "view" / "_etc_caddy_Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"непрочитанный маунт сочли доехавшим: {calls}\n{out}"
def test_unreadable_mount_list_falls_back_to_recreate(prod_tree: Path) -> None:
"""`docker inspect` не ответил → пересоздать, а не «расхождений нет».
Статус `$(docker inspect | while )` это статус `while`, то есть всегда
0, а `pipefail` в POSIX-sh не существует. Провал команды давал бы пустой
список маунтов, ветку «всё доехало» и зелёную строку «окна недоступности
нет» при прокси, работающем по СТАРОМУ конфигу тот самый беззвучный отказ,
ради которого написан скрипт.
"""
rc, out, calls = _run(prod_tree, FAKE_INSPECT_RC="1")
assert rc == 0, out
assert _recreated(calls), f"непрочитанный список маунтов сочли «всё доехало»: {calls}\n{out}"
assert not _reloaded(calls), f"reload вместо пересоздания: {calls}"
def test_no_file_mounts_is_not_silence(prod_tree: Path) -> None:
"""Ноль пофайловых маунтов — не «сверка прошла», а «сверять было нечем».
Так выглядит, например, перевод Caddyfile на именованный том: фильтр
`{{if eq .Type "bind"}}` перестаёт что-либо отбирать, и сверка становится
тавтологически успешной.
"""
root = prod_tree.parent.parent
(root / "mounts").write_text(
"".join(f"{prod_tree / rel}|{dst}\n" for rel, dst in DIR_MOUNTS.items()), encoding="utf-8"
)
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), f"пустая сверка сочтена успешной: {calls}\n{out}"
def test_missing_host_file_is_not_skipped_as_a_directory(prod_tree: Path) -> None:
"""Файла на хосте нет — это расхождение, а не «нечего сверять».
Пропуск по `[ -f "$src" ] || continue` склеивает два разных случая: каталог
(пропустить верно инод он не держит) и исчезнувший/нечитаемый файл, для
которого в контейнере как раз живёт старый инод со старым текстом. Файл
удаляется после проверки конфига (в тесте она подставная) проверяется
именно ветка сверки.
"""
(prod_tree / "Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"исчезнувший файл сочли доехавшим: {calls}\n{out}"
def test_log_says_how_many_mounts_were_compared(prod_tree: Path) -> None:
"""В логе должно быть ЧИСЛО сверенных файлов, а не только вердикт.
«Сверили пять» и «сверили ноль» обязаны различаться: иначе строка
«перезагружен без пересоздания» одинаково означает и проверку, и её
отсутствие.
"""
_, out, _ = _run(prod_tree)
assert re.search(r"сверено пофайловых маунтов[^\n]*: 5", out), (
f"скрипт не печатает число сверенных маунтов (их пять):\n{out}"
)
def test_validation_precedes_any_action(prod_tree: Path) -> None:
"""Проверка конфига идёт ПЕРВЫМ вызовом, до любого изменения состояния."""
_, out, calls = _run(prod_tree)
assert calls, f"скрипт не сделал ни одного вызова docker:\n{out}"
assert calls[0].startswith("run "), f"первым идёт не проверка конфига: {calls}"
assert "caddy validate" in calls[0], f"первый вызов — не validate: {calls[0]}"
# ── Проводка: оба пути деплоя зовут именно этот скрипт ───────────────────────
def _ssh_script(job: str) -> str:
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, f"в job `{job}` нет ровно одного ssh-шага — гейт #3443 ослеп"
script = steps[0]["with"]["script"]
assert script.strip(), f"ssh-скрипт job `{job}` пуст"
return script
def _commands(script: str) -> str:
"""Только команды: комментарии выкинуты, продолжения строк склеены.
Комментарии потому что разбор дефекта живёт в тех же файлах и содержит
его формулировку дословно: гейт по голому тексту краснел бы от объяснения,
а не от кода. Склейка `\\` потому что `--force-recreate` и имя сервиса
легко оказываются на РАЗНЫХ физических строках, и построчный поиск такую
запись не увидел бы (зелено по построению).
"""
kept = [ln for ln in script.splitlines() if not ln.lstrip().startswith("#")]
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
def _forced_caddy_recreates(commands: str) -> list[str]:
"""Строки, которые пересоздают именно сервис caddy."""
return [
ln
for ln in commands.splitlines()
if "--force-recreate" in ln and re.search(r"\bcaddy\b", ln)
]
def test_script_exists_and_is_the_one_under_test() -> None:
"""Признак непустоты: без скрипта проверки выше проходили бы вхолостую."""
assert SCRIPT.is_file(), f"нет {SCRIPT} — проводка ниже проверяла бы пустоту"
@pytest.mark.parametrize("job", ["deploy", "deploy-caddy"])
def test_deploy_applies_caddy_config_through_the_script(job: str) -> None:
assert "ops/caddy-apply.sh" in _commands(_ssh_script(job)), (
f"job `{job}` не зовёт ops/caddy-apply.sh — конфиг прокси применяется "
"мимо разбора #3443 (или безусловным пересозданием, или reload'ом, "
"который на пофайловом маунте читает старый инод)"
)
def test_full_deploy_has_no_unconditional_caddy_recreate() -> None:
"""Главный инвариант: в полном деплое нет безусловного пересоздания Caddy.
Возврат одной строки `up -d --force-recreate --no-deps caddy` в job `deploy`
возвращает 67-секундное окно `code=000` на всех доменах и не краснит
ничего: деплой остаётся зелёным, а увидеть отказ может только непрерывная
проба, запущенная ровно в эту минуту.
"""
bad = _forced_caddy_recreates(_commands(_ssh_script("deploy")))
assert not bad, (
"в полный деплой вернулось безусловное пересоздание Caddy:\n "
+ "\n ".join(bad)
+ "\nПересоздание обязано быть УСЛОВНЫМ — см. ops/caddy-apply.sh: compose "
"сам пересоздаёт контейнер при смене описания сервиса или образа, а "
"вручную это нужно только когда до контейнера не доехал пофайловый "
"bind-маунт (#3443)."
)
def test_failures_are_not_swallowed() -> None:
"""Ни применение конфига, ни сам reload не гасятся `|| true`.
Строка `caddy reload || true` в репозитории уже живёт
(deploy-tradein.yml), то есть это не гипотеза: с ней отказ применения
перестаёт краснеть, и «конфиг доехал» становится неотличимо от «команда
упала, а мы продолжили». Проверяется и вызов скрипта из обеих джоб, и
строка reload внутри самого скрипта.
"""
swallow = re.compile(r"\|\|\s*(true|:)\s*$")
offenders = []
for where, text in [
("deploy", _commands(_ssh_script("deploy"))),
("deploy-caddy", _commands(_ssh_script("deploy-caddy"))),
(SCRIPT.name, SCRIPT.read_text(encoding="utf-8")),
]:
for line in text.splitlines():
code = line.split("#", 1)[0] if not line.lstrip().startswith("#") else ""
if ("caddy-apply.sh" in code or "caddy reload" in code) and swallow.search(code):
offenders.append(f"{where}: {line.strip()}")
assert not offenders, "отказ применения конфига проглочен:\n " + "\n ".join(offenders)
def test_gate_runs_on_changes_to_the_script_itself() -> None:
"""CI-фильтр обязан пускать backend-тесты на правку ops/**.
Все содержательные регрессии живут в ops/caddy-apply.sh: проверки выше его
ИСПОЛНЯЮТ. Без `ops/**` в фильтре PR, правящий только скрипт, даёт
backend=false джоба пропускается, гейт не исполняется, и «пересоздавать
всегда» уезжает в main зелёным. Тот же класс, что #2950/#3448/#3467.
"""
spec = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))
steps = [
s
for job in spec["jobs"].values()
for s in job.get("steps") or []
if str(s.get("uses", "")).startswith("dorny/paths-filter")
]
assert steps, "в ci.yml не найден paths-filter — проверка прошла бы вхолостую"
patterns = [p for s in steps for p in yaml.safe_load(s["with"]["filters"]).get("backend") or []]
assert "ops/**" in patterns, (
f"фильтр backend не покрывает ops/** (сейчас: {patterns}) — гейт #3443 не "
"побежит на правке ops/caddy-apply.sh, то есть ровно на той правке, от "
"которой стережёт"
)
def test_gate_would_notice_the_regression() -> None:
"""Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя.
Без этого «не нашли force-recreate» неотличимо от «искали не там»: маска
поиска, промахнувшаяся мимо строки, выглядит зелёной ровно так же.
"""
regressed = _commands(
" # безусловное пересоздание caddy вернулось сюда\n"
" docker compose -p gendesign -f docker-compose.prod.yml up -d \\\n"
" --force-recreate --no-deps caddy\n"
)
assert _forced_caddy_recreates(regressed), (
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
)

View file

@ -0,0 +1,460 @@
"""Гейт: быстрый путь «правка только прокси» действительно включается (#3448).
ЧТО СЛУЧИЛОСЬ. Быстрый путь #2916 (`caddy_only` → джоба `deploy-caddy` с
`caddy reload` вместо пересоздания контейнеров) не отработал ни разу за всё
время жизни. Проверено на мерже 84920e6c, где в диффе ровно один файл
`caddy/sites/apps.caddy`: контейнеры пересозданы, в логе Caddy
`serving initial configuration` холодный старт, а не reload.
ПРИЧИНА НЕ пустой `github.event.before` (рабочая гипотеза #3448 опровергнута
логом задачи 29244: `before` = 204e2e09, `git diff` вернул ровно один файл).
Причина в том, что dorny/paths-filter склеивает шаблоны одного фильтра через
`some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`; параметр
`predicate-quantifier` по умолчанию `some`). Список
non_caddy: ['**', '!Caddyfile', '!caddy/**']
значит «подходит под `**` ИЛИ не Caddyfile ИЛИ не caddy/**», а `**` матчит всё
non_caddy был true ВСЕГДА. В логе это видно дословно:
##[group]Filter non_caddy = true
Matching files:
caddy/sites/apps.caddy [modified]
исключённый файл сам себя и «исключил».
ЗАЧЕМ ЭТОТ ФАЙЛ. У самой правки нет отрицательного признака: пропущенную джобу
Forgejo рисует зелёной, поэтому «зелёный deploy-caddy» одинаково выглядит и
когда быстрый путь сработал, и когда его вообще не было. Проверки ниже
ИСПОЛНЯЮТ шаг определения файлов из deploy.yml на настоящем временном
репозитории (включая мерж-коммит ровно случай #3448) и смотрят на значения
флагов, а не на текст воркфлоу. Так же исполняется и прод-сторож из джобы
`deploy-caddy`: быстрый путь пропускает джобу `deploy` целиком, а вместе с ней
и гард свежести :latest (#2950), поэтому перезагружать прокси можно, только
если прод отстаёт РОВНО на конфиг прокси. Регресс к исключающим шаблонам
paths-filter ловит отдельная проверка в конце.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
DEPLOY = WORKFLOWS / "deploy.yml"
NULL_SHA = "0" * 40
# Файлы, которые лежат в тестовом репозитории до правки. Набор подобран так,
# чтобы фолбэк «база не определена» мог отличить полный деплой от пустого.
BASE_FILES = (
"caddy/sites/apps.caddy",
"Caddyfile",
"backend/app/main.py",
"frontend/src/page.tsx",
"data/sql/001.sql",
"docker-compose.prod.yml",
"README.md",
)
GIT_ENV = {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.com",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.com",
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null",
}
# Эти кейсы гоняют `bash -c <скрипт>` во временном git-репозитории (POSIX shell +
# /dev/null + жёсткий PATH=/usr/bin:/bin:/usr/local/bin). На Windows дочерний
# процесс не стартует (нет POSIX shell на этом PATH); покрытие обеспечивается в
# CI на Linux. Кейсы, которые читают только YAML/строки без subprocess, не
# помечены и обязаны выполняться локально.
posix_only = pytest.mark.skipif(
sys.platform == "win32",
reason="запускает bash-скрипт дочерним процессом; POSIX-only, покрытие в CI (Linux)",
)
def _detect_script() -> str:
"""Тело шага, который считает изменённые файлы в job `changes`."""
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
job = spec["jobs"]["changes"]
steps = [s for s in job["steps"] if s.get("id") == "filter"]
assert len(steps) == 1, (
"в job `changes` нет ровно одного шага с `id: filter` — определение "
"изменённых файлов переехало, гейт #3448 ослеп"
)
step = steps[0]
assert "run" in step, (
f"шаг `filter` не считает файлы сам, а делегирует их {step.get('uses')!r}. "
"Именно так и возник #3448: у dorny/paths-filter шаблоны одного фильтра "
"склеиваются через ИЛИ, поэтому `non_caddy: ['**', '!caddy/**']` был true "
"ВСЕГДА и быстрый путь не включался ни разу."
)
assert step["run"].strip(), "шаг `filter` пуст"
return step["run"]
def _git(repo: Path, *args: str) -> None:
subprocess.run(
["git", "-C", str(repo), *args], check=True, env=dict(GIT_ENV), capture_output=True
)
def _sha(repo: Path) -> str:
return subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
env=dict(GIT_ENV),
).stdout.strip()
def _commit(repo: Path, files: tuple[str, ...], msg: str = "c") -> None:
for name in files:
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("changed\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", msg, *([] if files else ["--allow-empty"]))
def _base_repo(tmp_path: Path) -> tuple[Path, str]:
"""Репозиторий с одним базовым коммитом; возвращает его sha — это `before`."""
repo = tmp_path / "repo"
repo.mkdir(parents=True)
_git(repo, "init", "-q", "-b", "main")
for name in BASE_FILES:
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("base\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
return repo, _sha(repo)
def _merge_commit(repo: Path, changed: tuple[str, ...]) -> None:
"""Ветка с правкой и мерж `--no-ff` обратно в main.
Мерж, а не обычный коммит, потому что #3448 наблюдался именно на мерже
PR'а: у мерж-коммита две родительские линии, и любой разбор диффа обязан
работать на этой форме. Что `before` нельзя заменить на `HEAD^`, стережёт
отдельная проверка test_multi_commit_push_is_not_truncated: на ОДНОМ
мерж-коммите `HEAD^..HEAD` даёт верный ответ и такую подмену не ловит.
"""
_git(repo, "checkout", "-q", "-b", "feature")
_commit(repo, changed, "feature")
_git(repo, "checkout", "-q", "main")
_git(repo, "merge", "-q", "--no-ff", "-m", "merge feature", "feature")
def _exec(repo: Path, before: str, event: str = "push") -> tuple[dict[str, str], str]:
out_file = repo.parent / "outputs"
out_file.touch()
env = {
"PATH": "/usr/bin:/bin:/usr/local/bin",
"BEFORE": before,
"EVENT": event,
"GITHUB_OUTPUT": str(out_file),
**GIT_ENV,
}
proc = subprocess.run(
["bash", "-c", _detect_script()],
cwd=repo,
env=env,
capture_output=True,
text=True,
)
assert proc.returncode == 0, f"шаг упал:\n{proc.stdout}\n{proc.stderr}"
outputs = dict(
line.split("=", 1)
for line in out_file.read_text(encoding="utf-8").splitlines()
if "=" in line
)
return outputs, proc.stdout
def _run(
tmp_path: Path, changed: tuple[str, ...], *, before: str | None = None, event: str = "push"
) -> tuple[dict[str, str], str]:
repo, base_sha = _base_repo(tmp_path)
_merge_commit(repo, changed)
return _exec(repo, base_sha if before is None else before, event)
@posix_only
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
assert outputs["caddy_only"] == "true", (
f"быстрый путь не включился на правке ТОЛЬКО прокси: {outputs}. "
"Ровно это и есть #3448: deploy-caddy пропускается, идёт полный деплой "
"с пересозданием контейнеров, а Forgejo рисует пропуск зелёным."
)
assert outputs["backend"] == "false"
assert outputs["frontend"] == "false"
@posix_only
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
assert outputs["caddy_only"] == "false", (
f"быстрый путь включился, хотя вместе с конфигом приехал бэкенд: {outputs}. "
"Так прод остался бы на старом образе при зелёном деплое."
)
assert outputs["backend"] == "true"
@posix_only
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
"""База не разрешилась → полный деплой, а не пустой список.
Пустой список изменений это `caddy_only` без единого caddy-файла и
отключённая сборка: отказ, который выглядит как успешный быстрый путь.
"""
for before, event in ((NULL_SHA, "push"), ("", "push"), (None, "workflow_dispatch")):
outputs, log = _run(
tmp_path / f"case-{event}-{before!r}",
("caddy/sites/apps.caddy",),
before=before,
event=event,
)
assert outputs["caddy_only"] == "false", f"before={before!r} event={event}: {outputs}"
assert outputs["backend"] == "true", f"before={before!r} event={event}: {outputs}"
assert outputs["frontend"] == "true", f"before={before!r} event={event}: {outputs}"
assert outputs["infra"] == "true", f"before={before!r} event={event}: {outputs}"
assert "деплой полный" in log
@posix_only
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
"""Решение печатается: и список файлов, и итоговые флаги.
Без этого «сработало» и «просто не совпало» неотличимы единственным
свидетелем остаётся метка Created у контейнера на проде.
"""
_, log = _run(tmp_path, ("caddy/sites/apps.caddy",))
assert "caddy/sites/apps.caddy" in log, f"шаг не печатает список файлов:\n{log}"
assert "caddy_only=true" in log and "backend=false" in log, (
f"шаг не печатает итоговые флаги:\n{log}"
)
# ── Быстрый путь на самом проде: джоба deploy-caddy ──────────────────────────
#
# Пока caddy_only был мёртв, каждый push шёл полным деплоем, и гард свежести
# :latest (#2950, job `deploy`) прикрывал прод по умолчанию. Оживший быстрый
# путь его обходит: при caddy_only=true джоба `deploy` пропускается целиком.
# Дифф between-push (before→HEAD) не знает, что реально доехало до прода:
# отменённая очередью `deploy` предыдущего прогона оставляет прод на старом
# образе, а следующий caddy-only push честно видит «изменился один caddy-файл».
def _caddy_deploy_script() -> str:
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"]["deploy-caddy"]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, "в deploy-caddy нет ровно одного ssh-шага — гейт #3448 ослеп"
return steps[0]["with"]["script"]
def _prod_lag_guard() -> str:
"""Кусок ssh-скрипта от вычисления PROD_HEAD до `git reset --hard`."""
script = _caddy_deploy_script()
assert "PROD_HEAD=" in script, (
"джоба deploy-caddy не сверяет отставание прода: быстрый путь перезагрузит "
"прокси и уйдёт зелёным, оставив прод на старом образе (#3448)"
)
# Ищем КОМАНДУ, а не подстроку: `git reset --hard` упоминается выше в
# комментариях, и поиск по тексту нашёл бы объяснение вместо кода.
reset_cmd = re.search(r"(?m)^\s*git reset --hard", script)
assert reset_cmd, "в deploy-caddy пропал `git reset --hard` — гейт опирается на него"
start, reset = script.index("PROD_HEAD="), reset_cmd.start()
assert start < reset, (
"проверка отставания прода стоит ПОСЛЕ `git reset --hard` — при отказе "
"прод-HEAD уже переписан, и следующий прогон снова уйдёт быстрым путём"
)
return "set -euo pipefail\n" + script[start:reset]
def _prod_repo(tmp_path: Path, ahead: tuple[str, ...]) -> Path:
"""Прод-дерево на базовом коммите, origin/main — на `ahead` впереди."""
repo, base_sha = _base_repo(tmp_path)
_commit(repo, ahead, "ahead")
_git(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
_git(repo, "reset", "--hard", "-q", base_sha)
return repo
def _run_guard(repo: Path) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", _prod_lag_guard()],
cwd=repo,
capture_output=True,
text=True,
env={"PATH": "/usr/bin:/bin:/usr/local/bin", **GIT_ENV},
)
@posix_only
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
@posix_only
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
assert proc.returncode != 0, (
"быстрый путь разрешён, хотя прод отстаёт по коду бэкенда: перезагрузка "
f"прокси подменила бы выкатку, деплой ушёл бы зелёным.\n{proc.stdout}"
)
assert "backend/app/main.py" in proc.stdout, (
f"отказ не называет файлы, из-за которых он произошёл:\n{proc.stdout}"
)
def test_fast_path_takes_the_same_host_lock() -> None:
"""deploy-caddy правит прод-дерево — значит берёт тот же лок, что `deploy`.
Проверка текстовая, как в test_2950: исполнить flock-секцию в тесте нельзя,
а её пропажа не даёт ни одного сигнала до совпадения окон двух деплоев.
"""
script = _caddy_deploy_script()
assert "exec 9>/var/lock/gendesign-docker-deploy.lock" in script, (
"deploy-caddy делает `git reset --hard` в /opt/gendesign в обход лока, "
"которым полный деплой сериализует работу с прод-деревом (#2950)"
)
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
@posix_only
@pytest.mark.parametrize(
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
)
def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
tmp_path: Path, changed: tuple[str, ...]
) -> None:
"""`caddy-extra/…` и `Caddyfile.bak` — НЕ конфиг прокси.
Граница шаблона единственное, что отделяет быстрый путь от тихого
пропуска полного деплоя: `^(Caddyfile|caddy)` вместо `^(Caddyfile$|caddy/)`
отправил бы эти правки перезагружать прокси вместо выкатки.
"""
outputs, _ = _run(tmp_path, changed)
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
@posix_only
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
Без проверки «файлов больше нуля» пустой список формально удовлетворяет
«ни один файл не лежит вне caddy»: сборка отключается, деплой подменяется
перезагрузкой прокси отказ, выглядящий как успешный быстрый путь.
"""
outputs, log = _run(tmp_path, ())
assert outputs["caddy_only"] == "false", f"пустой дифф ушёл в быстрый путь: {outputs}"
assert "изменённых файлов: 0" in log
@posix_only
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
assert outputs["backend"] == "true", outputs
assert outputs["caddy_only"] == "false", outputs
@posix_only
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
"""Кириллица в пути не должна прятать файл от классификации.
`git diff --name-only` при `core.quotePath=true` (умолчание) отдаёт
не-ASCII пути закавыченными и с \\NNN-экранированием `^backend/`
такую строку не матчит. Старый paths-filter брал `--name-status -z`, где
квотирования нет; при переходе на свой diff это единственное место, где
поведение могло разойтись. В дереве такие пути уже живут (docs/).
"""
outputs, log = _run(tmp_path, ("backend/модуль.py",))
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
@posix_only
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
Ровно та подмена, которую соблазнительно сделать «чтобы не зависеть от
before»: `HEAD^..HEAD`. На одном мерж-коммите она даёт верный ответ и
выглядит рабочей, а здесь молча теряет бэкенд из первого коммита и
включает быстрый путь, то есть пропускает выкатку кода.
"""
repo, base_sha = _base_repo(tmp_path)
_commit(repo, ("backend/app/main.py",), "backend")
_commit(repo, ("caddy/sites/apps.caddy",), "caddy")
outputs, log = _exec(repo, base_sha)
assert outputs["backend"] == "true", f"первый коммит push'а потерян: {outputs}\n{log}"
assert outputs["caddy_only"] == "false", outputs
def _paths_filter_steps() -> list[tuple[Path, str, dict]]:
"""Все шаги dorny/paths-filter во всех воркфлоу (включая .yaml)."""
found = []
for path in sorted(WORKFLOWS.glob("*.y*ml")):
spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
for job_name, job in (spec.get("jobs") or {}).items():
for step in job.get("steps") or []:
if str(step.get("uses", "")).startswith("dorny/paths-filter"):
found.append((path, job_name, step))
return found
def test_exclusion_gate_has_something_to_check() -> None:
"""Признак непустоты: проверка ниже обязана что-то находить.
Переименуют действие, разнесут воркфлоу по .yaml, уедут шаги и гейт
пройдёт при нулевом охвате, молча (ровно то, от чего страхуется ci.yml:190).
"""
steps = _paths_filter_steps()
assert steps, (
"не найдено ни одного шага dorny/paths-filter — проверка исключающих "
"шаблонов прошла бы впустую, перепроверь маску поиска"
)
@pytest.mark.parametrize(
"path,job_name,step",
_paths_filter_steps(),
ids=[f"{p.name}:{j}" for p, j, _ in _paths_filter_steps()],
)
def test_no_paths_filter_relies_on_exclusion_patterns(
path: Path, job_name: str, step: dict
) -> None:
"""Ни один paths-filter в репозитории не вычитает пути через `!`.
Класс бага, а не единственный его случай: при `predicate-quantifier: some`
(умолчание) шаблоны фильтра склеиваются через ИЛИ, и `!` ничего не вычитает.
"""
with_ = step.get("with") or {}
if with_.get("predicate-quantifier") == "every":
pytest.skip("predicate-quantifier: every — шаблоны склеиваются через И")
filters = yaml.safe_load(with_.get("filters") or "") or {}
assert filters, f"{path.name}: job {job_name}у paths-filter пустой блок filters"
for filter_name, patterns in filters.items():
bad = [p for p in (patterns or []) if isinstance(p, str) and p.startswith("!")]
assert not bad, (
f"{path.name}: job {job_name}, фильтр {filter_name!r} вычитает пути "
f"шаблонами {bad} — при `some` (умолчание) они склеиваются через ИЛИ "
"и фильтр становится true ВСЕГДА. Так #2916 не сработал ни разу (#3448)."
)

View file

@ -0,0 +1,112 @@
"""Правки Prometheus-конфига/правил обязаны доезжать до работающего процесса.
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ. `GET /api/v1/status/runtimeinfo` внутри
`gendesign-prometheus` 12.09 отдавал `lastConfigTime`, совпадающий со
`startTime` контейнера, конфиг и правила не перечитывались 16 суток.
Деплой при этом был зелёный: `docker compose up -d` не пересоздаёт
контейнер из-за изменения содержимого бинд-маунта (он сравнивает только
описание сервиса), а `--web.enable-lifecycle` был включён в
docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не вызывал.
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
в этом же workflow только для Prometheus починка не пересоздание
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
пути заново, так что новый инод после `git reset --hard` подхватывается
без даунтайма.
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
подтверждён сменой lastConfigTime.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
def _text() -> str:
return WORKFLOW.read_text(encoding="utf-8")
def test_promtool_checks_config_and_rules() -> None:
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
text = _text()
assert "promtool check config /etc/prometheus/prometheus.yml" in text, (
"нет проверки конфига promtool'ом — битый prometheus.yml долетит до reload"
)
assert "promtool check rules" in text, (
"нет проверки правил promtool'ом — битое правило долетит до reload"
)
def test_reload_endpoint_is_called() -> None:
"""Сам reload обязан вызываться — иначе валидация ничего не решает."""
text = _text()
assert "localhost:9090/-/reload" in text, (
"нет вызова POST /-/reload — конфиг/правила проверяются, но в силу не вступают "
"(#3467: lastConfigTime не менялся 16 суток при зелёном деплое)"
)
def test_reload_happens_after_validation_not_before() -> None:
"""Reload обязан идти ПОСЛЕ promtool, а не до/вместо него."""
text = _text()
check_pos = text.index("promtool check config /etc/prometheus/prometheus.yml")
reload_pos = text.index("localhost:9090/-/reload")
assert check_pos < reload_pos, (
"reload стоит раньше проверки конфига — битый конфиг мог бы применяться вслепую"
)
def test_reload_is_guarded_by_the_promtool_check() -> None:
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
text = _text()
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
else_pos = text.index("else", guard_start)
reload_pos = text.index("localhost:9090/-/reload")
assert guard_start < reload_pos < else_pos, (
"вызов reload лежит вне ветки успешной проверки promtool — "
"битый конфиг всё равно приведёт к reload, либо reload вообще не защищён проверкой"
)
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
text = _text()
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
else_pos = text.index("else", guard_start)
fi_pos = text.index("fi", else_pos)
else_branch = text[else_pos:fi_pos]
assert "localhost:9090/-/reload" not in else_branch, (
"reload вызывается даже в ветке провалившейся проверки"
)
assert "exit 1" in else_branch, (
"провал promtool не роняет шаг — деплой останется зелёным при битом конфиге"
)
def test_acceptance_checks_last_config_time_actually_changed() -> None:
"""Приёмка обязана сверять `lastConfigTime` до/после, а не доверять коду ответа reload.
`wget` на POST /-/reload может отрапортовать успех, даже если Prometheus
молча остался на старом конфиге (например, если бинарь внутри образа не
поддерживает --post-data так, как ожидалось) единственное надёжное
подтверждение реального перечитывания конфига это смена таймстемпа.
"""
text = _text()
assert text.count("lastConfigTime") >= 2, (
"нет сравнения lastConfigTime до/после — reload не проверяется по факту"
)
assert "LAST_CONFIG_BEFORE" in text and "LAST_CONFIG_AFTER" in text, (
"нет явного до/после сравнения таймстемпа последней перезагрузки конфига"
)
verify_start = text.index("LAST_CONFIG_AFTER")
verify_block_end = text.index("Prometheus: конфиг и правила проверены", verify_start)
verify_block = text[verify_start:verify_block_end]
assert "exit 1" in verify_block, (
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
)

View file

@ -0,0 +1,108 @@
"""Гейт: `$value` в тексте алерта — это та величина, которую текст называет.
Найдено 12.09.2026 по боевым сообщениям в Telegram:
«apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.»
«apps / listings: доля HOT 75.21%.» (при пороге срабатывания «доля < 20%»)
Причина у обоих одна и она про ФОРМУ выражения, а не про условие. В PromQL
`A and B` возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. Значит в
`$value` попадает A, а не то отношение, ради которого правило написано:
- ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes`,
и в сообщение уходил ЛИМИТ В БАЙТАХ, отрендеренный `humanizePercentage`
(2 684 354 560 «2.684e+11%»). Условие при этом срабатывало верно.
- PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` АПДЕЙТОВ В
СЕКУНДУ. Это опаснее: 0.7521 превращалось в «75.21%», число попадало в
правдоподобный диапазон и противоречило собственному порогу («доля < 20%»),
но выглядело настоящим.
Отсюда инвариант, который здесь и проверяется: **если описание рендерит
`$value` как долю (`humanizePercentage`), выражение обязано возвращать долю**
то есть его ЛЕВАЯ часть (до первого `and`/`unless`) обязана содержать деление.
Отсев побочных условий переносится внутрь знаменателя (`X / (Y > 0)`), а не в
`and` слева.
Гейт намеренно не пытается «понять» PromQL целиком: он ловит ровно ту форму,
которая уже дважды уехала в прод, и не мешает правилам, печатающим абсолютные
величины без humanize (PostgresDeadTuplesHigh, PostgresIdleInTransaction).
"""
from __future__ import annotations
import re
from pathlib import Path
import yaml
_RULES_DIR = Path(__file__).resolve().parents[3] / "ops" / "metrics" / "prometheus" / "rules"
def _alerts() -> list[tuple[str, str, str, dict]]:
"""(файл, имя алерта, expr, annotations) по всем файлам правил."""
out: list[tuple[str, str, str, dict]] = []
for path in sorted(_RULES_DIR.glob("*.yml")):
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
for group in doc.get("groups", []):
for rule in group.get("rules", []):
if "alert" in rule:
out.append(
(
path.name,
rule["alert"],
rule.get("expr", ""),
rule.get("annotations") or {},
)
)
return out
def _left_of_and(expr: str) -> str:
"""Часть выражения ДО первого бинарного `and`/`unless` — её значения и видит $value."""
parts = re.split(r"\band\b|\bunless\b", expr, maxsplit=1)
return parts[0]
def test_rules_dir_is_found() -> None:
assert _RULES_DIR.is_dir(), f"нет каталога правил: {_RULES_DIR}"
assert _alerts(), "правила не распарсились — гейт был бы зелёным впустую"
def test_percentage_annotations_come_from_a_ratio() -> None:
"""Текст обещает долю → выражение обязано её и возвращать."""
broken: list[str] = []
for fname, name, expr, ann in _alerts():
text = " ".join(str(v) for v in ann.values())
if "humanizePercentage" not in text:
continue
left = _left_of_and(expr)
if "/" not in left:
broken.append(
f"{fname}::{name}: описание печатает $value как долю, но левая часть "
f"выражения (её и видит $value) деления не содержит: {' '.join(left.split())!r}"
)
assert not broken, "\n".join(broken)
def test_known_two_rules_are_fixed() -> None:
"""Именные проверки для двух правил, которые уже соврали в проде."""
by_name = {name: (expr, ann) for _, name, expr, ann in _alerts()}
expr, ann = by_name["ContainerNearMemoryLimit"]
flat = " ".join(expr.split())
assert flat.startswith("container_memory_working_set_bytes"), (
"слева должно стоять потребление, иначе в Telegram уедет лимит в байтах: " + flat
)
assert "(container_spec_memory_limit_bytes" in flat and "> 0)" in flat, (
"отсев нулевого лимита должен стоять В ЗНАМЕНАТЕЛЕ, а не в `and` слева: " + flat
)
assert "humanizePercentage" in " ".join(ann.values())
expr, ann = by_name["PostgresLowHotUpdateRatio"]
flat = " ".join(expr.split())
assert flat.startswith("rate(pg_table_write_amplification_tup_hot_upd"), (
"слева должен стоять числитель доли HOT, иначе печатается rate(tup_upd): " + flat
)
assert "/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5)" in flat, (
"гейт по объёму апдейтов должен жить в знаменателе — он же защищает от 0/0: " + flat
)

View file

@ -176,3 +176,29 @@ tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
# ── POSIX-only: тесты запускают дочерний bash-скрипт ───────────────────────────
# На Windows нет POSIX shell на PATH, которым эти тесты исполняют /bin/bash -c
# <скрипт> или сам shell-скрипт напрямую (шебанг #!/usr/bin/env bash) — процесс
# не стартует. В CI ЭТИ ТЕСТЫ ИДУТ (ubuntu-раннер). Записи нужны только для
# локального прогона на Windows; чисто-логические проверки в тех же файлах
# (читают YAML/строки без subprocess) в этот список НЕ входят и обязаны
# исполняться локально.
tests/ops/test_3448_caddy_only_detection.py::test_merge_with_only_caddy_file_takes_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_caddy_plus_backend_is_a_full_deploy
tests/ops/test_3448_caddy_only_detection.py::test_missing_base_falls_back_to_full_deploy
tests/ops/test_3448_caddy_only_detection.py::test_decision_is_visible_in_the_log
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_allowed_when_prod_lags_only_by_proxy_config
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_refuses_when_prod_lags_by_code
tests/ops/test_3448_caddy_only_detection.py::test_paths_that_merely_start_with_caddy_are_not_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_empty_diff_is_not_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_data_sql_counts_as_backend
tests/ops/test_3448_caddy_only_detection.py::test_non_ascii_path_is_classified
tests/ops/test_3448_caddy_only_detection.py::test_multi_commit_push_is_not_truncated
tests/test_2950_latest_image_revision_gate.py::test_fresh_latest_passes
tests/test_2950_latest_image_revision_gate.py::test_newer_revision_passes
tests/test_2950_latest_image_revision_gate.py::test_stale_latest_fails_after_timeout
tests/test_2950_latest_image_revision_gate.py::test_revision_from_other_component_does_not_cover
tests/test_2950_latest_image_revision_gate.py::test_missing_label_fails
tests/test_2950_latest_image_revision_gate.py::test_registry_error_fails
tests/test_2950_latest_image_revision_gate.py::test_usage_errors_are_distinct

View file

@ -15,10 +15,20 @@ from __future__ import annotations
import os
import stat
import subprocess
import sys
from pathlib import Path
import pytest
# Каждый тест в файле запускает scripts/check-latest-image-revision.sh (POSIX shell,
# shebang #!/usr/bin/env bash) дочерним процессом. На Windows нет POSIX shell на PATH,
# чтобы это исполнить, — покрытие обеспечивается в CI на Linux.
pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="запускает bash-скрипт (check-latest-image-revision.sh) дочерним процессом; "
"POSIX-only, покрытие в CI (Linux)",
)
КОРЕНЬ = Path(__file__).resolve().parents[2]
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"

View file

@ -0,0 +1,36 @@
"""Продуктовый счётчик экспорта отчётов (#3471): выгрузок §22-форсайта / ТЗ.
Мера кардинальности та же, что в `test_metrics.py`: единственный лейбл
`format`, фиксированный литерал из `Literal[...]` сигнатуры эндпоинта
(`export_parcel_forecast`) плюс одно статичное значение `best_layouts_pdf`
(ТЗ на проектирование) не кадастровый номер и не идентификатор пользователя.
"""
from __future__ import annotations
from prometheus_client import REGISTRY, generate_latest
from app.observability import metrics as m
def test_reports_exported_counter_has_bounded_format_label() -> None:
assert tuple(m.REPORTS_EXPORTED._labelnames) == ("format",) # type: ignore[attr-defined]
def test_reports_exported_counter_increments_per_format() -> None:
def _value(fmt: str) -> float:
return (
REGISTRY.get_sample_value("sitefinder_reports_exported_total", {"format": fmt}) or 0.0
)
before_pdf = _value("pdf")
before_layouts = _value("best_layouts_pdf")
m.REPORTS_EXPORTED.labels(format="pdf").inc()
m.REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
assert _value("pdf") - before_pdf == 1.0
assert _value("best_layouts_pdf") - before_layouts == 1.0
body = generate_latest(REGISTRY).decode()
assert "sitefinder_reports_exported_total" in body

View file

@ -42,6 +42,40 @@
# код.
# ═══════════════════════════════════════════════════════════════════════════
# ── (tradein_frontend_retry) — подмена контейнера фронта без 502 (#3274) ─────
#
# ЧТО ЭТО. Ретрай ПОДКЛЮЧЕНИЯ к tradein-frontend: пока идёт подмена
# контейнера, Caddy не отдаёт ошибку сразу, а до 2 с переспрашивает апстрим с
# шагом 100 мс. Импортируется ВНУТРЬ каждого `reverse_proxy tradein-frontend`
# в этом файле (9 блоков: gendsgn.ru/trade-in/* и все публичные пути
# meraocenka.ru).
#
# ПОЧЕМУ ЭТО НЕ ТО, ЧТО ОТВЕРГНУТО В #3274. Там `lb_try_duration` отвергнут
# для окна 3090 с — держать посетителя минуту в ожидании хуже честной
# ошибки, инструмент рассчитан на разрыв в сотни миллисекунд. Правка в
# deploy-tradein.yml (frontend вынесен из общего `up -d`) СНАЧАЛА сводит окно
# к этим сотням миллисекунд, и только после этого ретрай становится
# применим. Числа, на которых это стоит (замеры 11.09):
# - прод, ОДИН сервис в `up -d`: контейнер создан 16:42:17.5 → запущен
# 16:42:18.0 — 0,5 с;
# - прод, ПАЧКА сервисов в одном `up -d`: создан 15:01:40 → запущен
# 15:02:10 — 30 с (фаза start ждёт готовности зависимостей, а старый
# контейнер снесён ещё в фазе create);
# - стенд (реальный образ фронта + Caddy 2), одиночная подмена: без ретрая
# 1 × 502, с ретраем 241/241 × 200, один запрос подождал 0,55 с.
#
# ПОТОЛОК. 2 с — это ЦЕНА ОЖИДАНИЯ при настоящей аварии: если фронт лежит
# долго, каждый запрос сначала висит 2 с и лишь потом получает 503-заглушку
# (../deploy-window.caddy.snippet). Поэтому не «побольше на всякий случай»:
# запас над измеренными 0,5 с четырёхкратный, дальше растёт только вред.
# Бэкенду (tradein-backend) этот приём НАМЕРЕННО не дан: его старт — единицы
# секунд (uvicorn + подключение к БД), там 2 с не хватит, а больше — уже тот
# самый вред. Его окно закрывается отдельно, не этой правкой.
(tradein_frontend_retry) {
lb_try_duration 2s
lb_try_interval 100ms
}
# Caddy config.
#
# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt.
@ -99,6 +133,35 @@ gendsgn.ru {
format json
}
# #3471: копия access-лога на stdout. За 3 часа наблюдения в
# gendesign-caddy-1 не было НИ ОДНОЙ строки http.log.access — только ACME/
# TLS/warn от reverse_proxy, статусы/латентность/RPS прокси не видны в
# Loki вообще. Alloy на этом хосте уже собирает stdout контейнеров через
# journald (`loki.source.journal "host"`,
# ops/metrics/alloy/alloy-apps.alloy) — второй bind-монт не нужен.
#
# Секреты в query (`?secret=`, `?token=` и т.п., см. инцидент #3154) режет
# УЖЕ РАБОТАЮЩИЙ `loki.process.scrub_credentials` (#3354, тот же список
# имён параметров, что в app/core/log_scrub.py) — он стоит на пути ЛЮБОГО
# journal-лога, включая этот, поэтому второй слой скраба здесь не заводим.
#
# `log_skip` — общий для ВСЕХ логгеров сайта флаг на запрос (Caddy не даёт
# скипать выборочно по конкретному логгеру), поэтому /health и статика
# Next пропадают заодно и из файлового gendsgn.ru.log выше, не только из
# копии на stdout. Это осознанный побочный эффект, не только экономия:
# /health — 32% строк gendsgn.ru.log в измеренном сегменте (12.09.2026,
# 240 из 742 строк за ~4ч, аптайм-монитор раз в минуту) без диагностической
# ценности, и именно он гонит файловый лог через 50MiB roll_size так часто.
log access_stdout {
output stdout
format json
}
@noisy_access_log {
path /health /_next/static/*
}
log_skip @noisy_access_log
route {
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
@ -121,10 +184,13 @@ gendsgn.ru {
}
# Static HTML mockups для review (audit alternatives).
# Public access — без auth (по запросу 2026-05-17).
# Public access — без auth (по запросу 2026-05-17) — это намеренно и
# менять НЕ надо. Но каталог не должен листингом отдавать список
# файлов случайному посетителю: без `browse` file_server продолжает
# отдавать сами файлы по прямой ссылке, просто без index-листинга.
handle_path /preview/* {
root * /srv/preview
file_server browse
file_server
}
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
@ -137,6 +203,7 @@ gendsgn.ru {
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview {
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
@ -240,6 +307,7 @@ gendsgn.ru {
handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
# причине здесь больше нет инжекта X-Internal-Auth-Secret
@ -335,10 +403,58 @@ www.gendsgn.ru {
meraocenka.ru {
encode zstd gzip
# #3471 (security-headers follow-up): публичный B2C-домен без базовых
# security-заголовков. Позиция директивы в файле не важна — Caddyfile
# применяет директивы в фиксированном порядке (`header` идёт раньше
# `reverse_proxy`), не по строке в исходнике.
#
# Проверено: ни tradein-mvp/frontend (обслуживает этот домен, отдаёт
# `/trade-in/mera-public*`), ни его next.config.ts/middleware не
# выставляют ни один из этих заголовков — дублирования нет.
header {
# 180 суток (15552000s). НЕ ставить includeSubDomains и НЕ ставить
# preload — оба флага практически необратимы (браузеры кэшируют
# preload-список надолго, includeSubDomains ломает любой будущий
# HTTP-поддомен), а не все поддомены meraocenka.ru гарантированно
# под нашим контролем. Если это когда-нибудь понадобится — отдельное
# осознанное решение, не «улучшение по умолчанию».
Strict-Transport-Security "max-age=15552000"
# Запрет встраивания во фрейм: X-Frame-Options — для старых
# клиентов, которые не понимают CSP; frame-ancestors — современный
# механизм. Оба вместе, не один вместо другого.
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
# Не даём стеку светиться в ответе. Полноценный CSP
# (default-src/script-src/...) сюда намеренно НЕ входит — отдельная
# работа с риском сломать фронт, здесь только периметр вокруг фрейма.
-Server
-X-Powered-By
}
log {
output file /var/log/caddy/meraocenka.ru.log
}
# #3471: та же копия access-лога на stdout, что у gendsgn.ru — см.
# развёрнутый комментарий там (Alloy/journald, scrub_credentials #3354,
# log_skip общий на все логгеры сайта). `/trade-in/_next/static/*` — 5.1%
# строк meraocenka.ru.log в измерении 12.09.2026 (3782 из 73810 за
# ~17.6 суток без ротации, roll_size здесь вообще не настроен) — статика
# Next не может быть источником 5xx бэкенда. `/health` на этом домене не
# проксируется (allowlist ниже отдаёт по нему 404), но матчер добавлен для
# единообразия с gendsgn.ru — вреда от него ноль.
log access_stdout {
output stdout
format json
}
@noisy_access_log {
path /health /trade-in/_next/static/*
}
log_skip @noisy_access_log
# `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
# финальный `handle { respond 404 }`, так что путь и без этой строки не
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
@ -355,6 +471,7 @@ meraocenka.ru {
handle / {
rewrite * /trade-in/mera-public
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
# тем более может прислать свой X-Authenticated-User. Сейчас
@ -394,10 +511,18 @@ meraocenka.ru {
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business
# /sitemap.xml дописан той же строкой, а не отдельным handle (как
# robots.txt ниже): Next отдаёт его по /trade-in/mera-public/sitemap.xml
# (src/app/mera-public/sitemap.ts) — ровно в форме, которую уже собирает
# rewrite `/trade-in/mera-public{path}` ниже. robots.txt так не смог бы:
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
# robots.txt существует только в корне app/, не под mera-public/), эта
# форма под `{path}` не попадает — отсюда отдельный handle у него.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /articles/skolko-stoit-metr-v-ekaterinburge /articles/pochemu-kvartira-ne-prodaetsya /docs /business /sitemap.xml
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
@ -408,9 +533,28 @@ meraocenka.ru {
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
# БАГ, найденный замером на живом проде 10.09.2026:
# https://meraocenka.ru/articles/?utm_source=vc -> Location:
# https://meraocenka.ru/articles — query терялся, потому что
# `redir * /{re.shortslash.1}` собирает цель заново из regex-захвата
# пути и не переносит исходную query-строку. Вред тихий: статьи
# публикуются с UTM-метками, а мессенджеры и автолинкификаторы дописывают
# слэш к скопированной ссылке — то есть именно трафик по опубликованному
# посту терял атрибуцию, и 301 при этом честно отрабатывал (просто не
# туда), ошибки никто не видел.
#
# Тот же приём, что у @meraLongPages/@meraV3Gone ниже и выше: `uri
# strip_suffix /` снимает ровно завершающий слэш (сам путь уже сужен
# матчером до канонического короткого вида), `redir * {uri} permanent`
# переносит query как есть. Обёртка `route` обязательна по той же
# причине, что у соседей — внутри `handle` порядок директив определяет
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|articles/skolko-stoit-metr-v-ekaterinburge|articles/pochemu-kvartira-ne-prodaetsya|docs|business)/$
handle @meraShortSlash {
redir * /{re.shortslash.1} permanent
route {
uri strip_suffix /
redir * {uri} permanent
}
}
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
@ -493,7 +637,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge/ /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
handle @meraLongPages {
route {
uri strip_prefix /trade-in/mera-public
@ -545,6 +689,7 @@ meraocenka.ru {
respond @foreignRouteChunk 404
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
@ -555,6 +700,53 @@ meraocenka.ru {
handle /favicon.ico {
rewrite * /trade-in/favicon.ico
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# robots.txt — ОТДЕЛЬНЫЙ handle, а не строка в @meraPages выше: у Next
# robots.txt — конвенция корня app/ (src/app/robots.ts), а не
# mera-public/, поэтому rewrite `/trade-in/mera-public{path}` его не
# обслужит — нужен свой rewrite на /trade-in/robots.txt. По образцу
# favicon.ico прямо над этим блоком, включая тот же периметр-скраб
# заголовка.
handle /robots.txt {
rewrite * /trade-in/robots.txt
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# og-mera.png — картинка превью ссылок (og:image). Её просят НЕ браузеры
# посетителей, а обходчики мессенджеров и соцсетей (Telegram, VK,
# WhatsApp), причём анонимно и без Referer: если путь не открыт, ссылка на
# сайт разворачивается без картинки, и понять это по логам приложения
# нельзя — запрос просто не доходит.
#
# Отдельный handle по той же причине, что у robots.txt: файл лежит в
# `public/` фронта и раздаётся Next'ом по basePath-корню
# (/trade-in/og-mera.png), а не под /trade-in/mera-public{path}, куда
# рерайтит @meraPages. Адрес намеренно короткий: он попадает в разметку
# каждой публичной страницы и расходится по чужим кэшам, так что менять
# его потом дорого.
handle /og-mera.png {
rewrite * /trade-in/og-mera.png
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# logo-mera.png — логотип организации из микроразметки (Organization.logo).
# Отдельный файл от og-mera.png выше: превью ссылки широкое, а логотип
# поисковик обрезает близко к квадрату. Свой handle по той же причине —
# файл лежит в `public/` и раздаётся по basePath-корню.
handle /logo-mera.png {
rewrite * /trade-in/logo-mera.png
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}

View file

@ -105,11 +105,44 @@ metrics.gendsgn.ru {
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
# поставил не человек. Прав в системе токен не даёт никаких.
#
# Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
# Сервис отвечает только на /ack/*, /glitchtip и /healthz; всё прочее — 404.
handle /ack/* {
reverse_proxy alert-ack:8080
}
# Резервный приёмник алертов GlitchTip (#3471). Основной получатель —
# продуктовый бэкенд на Selectel, то есть тот самый сервис, за которым эти
# алерты и следят: пока он лежит, его собственные ошибки доставлять некому.
# Этот путь живёт у другого провайдера и с чистой сетью до Telegram, поэтому
# переживает падение Selectel целиком.
#
# Секрет — в значении query-параметра, а не в пути: путь сам по себе не
# секрет, и его попадание в access-лог безопасно. Само значение вырезает
# scrub_credentials в Alloy до записи в Loki (#3154).
handle /glitchtip* {
reverse_proxy alert-ack:8080
}
# Ретранслятор Bot API продукта (#3471). Путь от Selectel до
# api.telegram.org теряет примерно каждый четвёртый короткий запрос, тот же
# замер с Beget в те же минуты — чистый. Поэтому продуктовый бот ходит в
# Telegram отсюда, а не напрямую.
#
# Путь содержит токен бота (/tg-relay/bot<TOKEN>/<method>) — он не должен
# осесть ни в файловом логе сайта, ни в stdout-копии (#3154), поэтому
# запрос помечен log_skip.
@tg_relay path /tg-relay/*
log_skip @tg_relay
handle_path /tg-relay/* {
reverse_proxy tg-relay:8080 {
# getUpdates — long-poll до ~40с, дефолтный таймаут ответа короче.
transport http {
response_header_timeout 80s
}
}
}
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.

View file

@ -231,6 +231,71 @@ services:
mem_limit: 128m
logging: *default-logging
# ── redis-exporter: здоровье общего Redis (только на Poincare) ───────────────
# #3471. Redis — один инстанс на три потребителя: db0 celery-брокер Site
# Finder, db1 SearchCache trade-in, db2 glitchtip (см. комментарий у сервиса
# `redis` в docker-compose.prod.yml). Один `redis_up` покрывает риск для всех
# трёх разом — до этой правки Redis не измерялся вообще, переполнение
# брокера и обычная недоступность снаружи выглядели одинаково — тишиной.
#
# Адрес — через alias `gendesign-redis`, который `redis` регистрирует на
# сети `shared` (см. #2709 в docker-compose.prod.yml) — джойнить ещё и
# `product` не нужно, тем же путём уже идёт postgres-exporter-tradein.
#
# Пароль — из окружения, не хардкод: сегодня на Redis нет requirepass (нет
# переменной ни в docker-compose.prod.yml, ни здесь), но если он появится,
# значение подставляется через METRICS_REDIS_PASSWORD в /opt/gendesign/.env
# на хосте, а не в этот файл.
redis-exporter:
image: oliver006/redis_exporter:v1.65.0
container_name: gendesign-redis-exporter
restart: unless-stopped
profiles: ["apps"]
environment:
REDIS_ADDR: ${METRICS_REDIS_ADDR:-redis://gendesign-redis:6379}
REDIS_PASSWORD: ${METRICS_REDIS_PASSWORD:-}
expose:
- "9121"
networks:
- shared
mem_limit: 64m
logging: *default-logging
# ── celery-exporter: очередь Site Finder (только на Poincare) ────────────────
# #3471. Слепая зона: глубина очереди, число живых воркеров и счётчик
# неуспешных задач нигде не измерялись — залипший воркер и переполненная
# очередь снаружи неотличимы от тишины.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ ОБРАЗ, А НЕ redis-exporter --check-keys. check-keys дал
# бы LLEN дефолтной очереди "celery" (в backend/app/workers/celery_app.py
# НЕТ task_routes — все таски идут в один дефолтный queue, имя буквально
# "celery") без нового образа вообще. Но он НЕ умеет считать живых
# воркеров и неуспешные таски — то есть закрыл бы только треть минимума
# из задачи. celery-exporter слушает событийную шину Celery через тот же
# брокер и даёт все три метрики разом, поэтому выбран он, а не комбинация
# check-keys + что-то ещё для остальных двух чисел.
#
# ⚠️ ИМЕНА МЕТРИК НИЖЕ (celery_queue_length, celery_worker_up,
# celery_task_failed_total) — по документации проекта на момент правки, БЕЗ
# прогона на реальном брокере (агент писал этот файл без доступа к проду).
# Сверить с `curl http://gendesign-celery-exporter:9808/metrics` на хосте
# после первого деплоя и поправить `ops/metrics/prometheus/rules/infra.yml`
# при расхождении — иначе алерты будут молча ничего не ловить.
celery-exporter:
image: danihodovic/celery-exporter:0.13.0
container_name: gendesign-celery-exporter
restart: unless-stopped
profiles: ["apps"]
command:
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
- "--queue=celery"
expose:
- "9808"
networks:
- shared
mem_limit: 128m
logging: *default-logging
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.

View file

@ -196,6 +196,12 @@ services:
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
# но уйдёт: алерт важнее подтверждения.
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru}
# Резервный получатель GlitchTip-алертов (#3471, POST /glitchtip) — второй
# получатель наряду с основным вебхуком в продуктовый бэкенд на Selectel.
# Секрет СВОЙ, не общий с продуктовым TRADEIN_INTERNAL_AUTH_SECRET: разные
# хосты/домены безопасности. Пусто — эндпоинт отвечает 503, остальной
# функционал сервиса не затронут.
ALERT_ACK_GLITCHTIP_SECRET: ${ALERT_ACK_GLITCHTIP_SECRET:-}
volumes:
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
expose:
@ -210,7 +216,74 @@ services:
timeout: 10s
retries: 5
# ── Ретранслятор Bot API продукта через Beget (#3471) ───────────────────────
#
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
# `tradein-tgbot` на Selectel — 9 успешных из 12, три ConnectTimeout. TCP-443
# до адреса, резолвящегося на Selectel — 5/6. Тот же TCP-443 до адреса,
# резолвящегося на Beget — 8/8. За сутки 508 строк `network error` в логе
# бота, за 30 дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel
# лоссовый, с Beget чистый — Alertmanager (тот же чат, живёт рядом) шлёт без
# проблем. Продуктовые sendMessage/copyMessage/getUpdates идут сюда вместо
# прямого пути; выключается пустым TELEGRAM_RELAY_BASE_URL на стороне
# продукта — это и есть откат.
#
# НЕ рядом с продуктом: смысл ретранслятора именно в том, что он живёт там,
# откуда путь до Telegram чистый, а не там, откуда он лоссовый.
#
# Образ без сборки и без зависимостей (только stdlib) — тот же принцип, что у
# alert-ack: сервис обязан подниматься даже когда сломано всё остальное.
tg-relay:
image: python:3.12-slim
container_name: gendesign-tg-relay
# #3471 (PR #3487 инцидент): без profiles сервис поднимался ВСЕГДА, а при
# пустом TG_RELAY_SECRET делает SystemExit — то есть уходит в бесконечный
# Restarting сразу после деплоя. Профиль включает deploy-metrics.yml, и
# только когда секрет реально задан (см. PROFILES там).
profiles: ["relay"]
restart: unless-stopped
user: "65534:65534"
command: ["python", "-u", "/app/app.py"]
env_file:
- path: ./backend/.env.runtime
required: false
- path: ./backend/.env
required: false
environment:
# Общий секрет с продуктовым клиентом (TELEGRAM_RELAY_SECRET на стороне
# tradein-backend/tradein-tgbot) — домен публичный, без секрета отказ.
TG_RELAY_SECRET: ${TG_RELAY_SECRET:-}
volumes:
- ./ops/metrics/tg-relay/app.py:/app/app.py:ro
expose:
- "8080"
networks:
- shared
mem_limit: 128m
logging: *default-logging
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz',timeout=5)"]
interval: 30s
timeout: 10s
retries: 5
# ── Grafana: витрина ─────────────────────────────────────────────────────────
# Grafana здесь ТОЛЬКО рисует — не решает, что считать инцидентом и куда его
# слать. Тревоги живут в Prometheus (правила) и Alertmanager (маршрутизация,
# Telegram); это единственный путь доставки (#3158).
#
# Встроенный Alerting выключен ЯВНО, а не просто «не настроен». Проверка на
# живом API 12.09.2026 нашла: 0 правил, единственный контакт-поинт —
# стоковый grafana-default-email на example@email.com, GF_SMTP_* не заданы.
# То есть кнопка «New alert rule» в интерфейсе есть и работает, а результат
# молча уходит в никуда — ровно та ситуация, из-за которой никто не проверяет
# второй, настоящий путь. Дублирующий движок на том же датасорсе Prometheus
# надёжности всё равно не прибавляет (общая точка отказа), только даёт второе
# место, где правило может быть заведено и забыто.
#
# Если это когда-нибудь понадобится включить обратно — сначала подключить
# реальный SMTP или другой contact point и завести хотя бы одно тестовое
# правило руками, иначе вернётся тот же капкан.
grafana:
image: grafana/grafana:11.5.1
container_name: gendesign-grafana
@ -230,6 +303,12 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
# Единственный официальный переключатель Grafana Alerting в 11.x — секция
# [unified_alerting], легаси-[alerting] удалён из Grafana ещё в 9.0 и в
# 11.5 в конфиге отсутствует (сверено с grafana.com/docs/grafana/v11.5/
# setup-grafana/configure-grafana/#unified_alerting). false здесь убирает
# раздел Alerting из UI и глушит движок правил целиком — см. #3158 выше.
GF_UNIFIED_ALERTING_ENABLED: "false"
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
GF_ANALYTICS_REPORTING_ENABLED: "false"

138
ops/caddy-apply.sh Executable file
View file

@ -0,0 +1,138 @@
#!/bin/sh
# Применить текущий конфиг прокси к работающему Caddy (#3443).
#
# ЗАЧЕМ. Полный деплой ПТИЦЫ пересоздавал сам Caddy БЕЗУСЛОВНО
# (`up -d --force-recreate --no-deps caddy`), а вместе с контейнером исчезал
# единственный процесс, слушающий 80/443. Замер 05.09 (#3274): 67 с `code=000`
# на ВСЕХ доменах хоста — gendsgn.ru, meraocenka.ru и зеркала, включая
# публичный лендинг МЕРЫ. Это не 502/503: принимающего процесса нет вовсе,
# поэтому заглушка окна деплоя (caddy/sites/deploy-window.caddy.snippet) здесь
# бессильна по построению — её отдаёт тот же Caddy.
#
# ЧТО НА САМОМ ДЕЛЕ ТРЕБУЕТ ПЕРЕСОЗДАНИЯ. Безусловный флаг появился 17.05
# (11e78d73) ради нового bind-маунта `./preview` из docker-compose.prod.yml,
# который «не появлялся в running container». Довод неверен: `docker compose
# up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам, как только меняется
# описание сервиса или образ. Проверено на живом демоне (docker 28.4):
# добавлен volume → `Container … Starting/Started`, id контейнера новый;
# тег указан на др. образ → id новый;
# не менялось ничего → `Container … Running`, id тот же.
#
# Остаётся ровно один класс изменений, которого compose не видит: СОДЕРЖИМОЕ
# пофайлового bind-маунта. `git reset --hard` не правит файл на месте, а пишет
# новый инод; контейнер держит примонтированным прежний и продолжает читать
# его — `caddy reload` перечитает ровно тот же старый инод. Тот же механизм уже
# ловили на Alertmanager (27.08, deploy-metrics.yml) и на Alloy (#3380). У Caddy
# так смонтированы пять путей: Caddyfile и четыре сниппета. Каталоги
# (caddy/sites, caddy/local, preview) этим не страдают — правка внутри каталога
# видна контейнеру сразу, поэтому самый частый случай (caddy/sites/apps.caddy)
# пересоздания НЕ требует.
#
# ОТСЮДА ПОРЯДОК: проверить конфиг → `up -d` без `--force-recreate` → если
# контейнер остался тем же, сверить, видит ли он текущее содержимое пофайловых
# маунтов → пересоздать ТОЛЬКО при расхождении, иначе `caddy reload`, который
# не рвёт соединения.
#
# ГРАНИЦА. Сверка по СОДЕРЖИМОМУ, а не по иноду: файл, переписанный тем же
# текстом, пересоздания не требует. Не прочиталось (контейнер не запущен, в
# образе нет sha256sum) — считаем расхождением: fail-safe в сторону прежнего
# поведения, то есть пересоздания.
set -eu
# Оба вызывающих (job `deploy` и job `deploy-caddy` в .forgejo/workflows/deploy.yml)
# работают в /opt/gendesign, но не зависеть от cwd дешевле, чем это помнить.
cd "$(dirname "$0")/.."
COMPOSE="docker compose -p gendesign -f docker-compose.prod.yml"
caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; }
# ── 1. Проверка ДО применения ────────────────────────────────────────────────
# Одноразовый контейнер читает файлы С ХОСТА — то есть ровно то, что поедет в
# работающий Caddy. `exec caddy validate` здесь не годится: он проверил бы
# старый инод, то есть предыдущую версию конфига. Образ и парсер те же, что на
# PR-гейте (ci.yml «Guard: Caddyfile синтаксически валиден»).
#
# ГРАНИЦА ЭТОЙ ПРОВЕРКИ. Она обрывает применение до того, как конфиг попадёт в
# работающий Caddy, — но только на быстром пути. В полном деплое ВЫШЕ по
# скрипту (deploy.yml, `up -d $UP_SERVICES`) уже прошёл общий подъём всех
# сервисов, и если правка одновременно ломает Caddyfile И меняет блок caddy в
# docker-compose.prod.yml, контейнер пересоздастся там — с непроверенным
# конфигом и раньше этой строки. Первая линия против этого — гейт на PR (#2913).
echo "→ проверяю конфиг прокси одноразовым контейнером…"
if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \
caddy validate --config /work/Caddyfile --adapter caddyfile; then
echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось"
echo " запустить проверочный контейнер (нет образа caddy:2, занят демон,"
echo " недоступен реестр). Причина — в выводе выше, не гадать по этой строке."
echo " Работающий Caddy не тронут, домены живы."
exit 1
fi
# ── 2. Описание сервиса и образ ──────────────────────────────────────────────
before=$(caddy_cid)
$COMPOSE up -d --no-deps caddy
after=$(caddy_cid)
if [ -z "$after" ]; then
echo 'ОШИБКА: после `up -d` контейнера caddy нет — смотри вывод compose выше.'
exit 1
fi
if [ "$before" != "$after" ]; then
echo "✓ Caddy пересоздан compose'ом: изменилось описание сервиса или образ (${before:-нет}${after})."
exit 0
fi
# ── 3. Доехало ли содержимое пофайловых маунтов ──────────────────────────────
# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в
# `$(docker inspect … | while …)` статус подстановки — это статус `while`, то
# есть всегда 0 (`pipefail` в POSIX-sh нет вовсе). Провал `docker inspect`
# давал бы пустой список → «расхождений нет» → `caddy reload` → зелёная джоба с
# надписью «окна недоступности нет», а прокси работал бы по СТАРОМУ конфигу.
# Это ровно тот беззвучный отказ, ради которого написан весь скрипт.
mounts=$(docker inspect "$after" \
--format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}') \
|| mounts=''
if [ -z "$mounts" ]; then
echo "WARNING: список маунтов Caddy не прочитан (docker inspect молчит или упал) —"
echo " сверить нечем, считаю расхождением: fail-safe в прежнее поведение."
verdicts=""
stale="(маунты не прочитаны)"
else
verdicts=$(printf '%s\n' "$mounts" | while IFS='|' read -r src dst; do
[ -n "${dst:-}" ] || continue
# Каталог инод не держит — пропускаем. Именно `-d`, а не `-f`:
# отсутствующий/нечитаемый ФАЙЛ — не повод молча пропустить, в
# контейнере в этот момент живёт старый инод со старым текстом.
if [ -d "$src" ]; then continue; fi
host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1)
seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1)
if [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕРОЧИТАНО}" ]; then
echo "OK $dst"
else
echo "STALE $dst"
fi
done)
# «Сверили пять файлов» и «сверили ноль» обязаны различаться в логе — иначе
# зелёная строка ниже одинаково означает и проверку, и её отсутствие.
checked=$(printf '%s\n' "$verdicts" | grep -c . || true)
echo "→ сверено пофайловых маунтов с тем, что видит контейнер: $checked"
stale=$(printf '%s\n' "$verdicts" | sed -n 's/^STALE //p' | tr '\n' ' ')
if [ "$checked" -eq 0 ]; then
echo "WARNING: ни одного пофайлового bind-маунта не найдено — у Caddy их пять"
echo " (Caddyfile + 4 сниппета). Считаю расхождением: fail-safe."
stale="(пофайловых маунтов не найдено)"
fi
fi
if [ -n "$stale" ]; then
echo "→ до контейнера НЕ доехали пофайловые маунты: $stale"
echo " (bind-маунт файла держит инод: reload перечитал бы старую версию — нужен recreate)"
$COMPOSE up -d --force-recreate --no-deps caddy
echo "✓ Caddy пересоздан — иначе правка осталась бы неприменённой."
exit 0
fi
# ── 4. Всё доехало — перезагрузка без разрыва соединений ─────────────────────
$COMPOSE exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."

View file

@ -27,6 +27,28 @@
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440)
ALERT_ACK_GLITCHTIP_SECRET секрет резервного вебхука GlitchTip (#3471,
см. POST /glitchtip ниже); пусто 503
РЕЗЕРВНЫЙ КАНАЛ GLITCHTIP (#3471). Все три alert-правила GlitchTip (backend,
frontend, Trade-In) шлют основной вебхук в продуктовый бэкенд на Selectel
тот самый хост, за которым они следят. Если там упал сам бэкенд или Caddy,
алерт об этом теряется именно тогда, когда нужнее всего. `POST /glitchtip`
второй получатель того же алерта, зарегистрированный в GlitchTip отдельной
строкой; живёт на ЭТОМ (инфраструктурном, Beget) хосте и не зависит от
здоровья продукта. Формат тела тот же Slack-совместимый payload, что и у
продуктового приёмника (`tradein-mvp/backend/app/api/v1/glitchtip.py`):
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields":[{"title","value"}]}]}``, GlitchTip заголовков не шлёт вовсе
аутентификация только через секрет в query (``?secret=``) или в заголовке
``X-GlitchTip-Secret`` (тот же выбор, что там же и по той же причине: заголовок
не течёт в access-log, query остаётся, т.к. сам GlitchTip 6.1.6 заголовков не
добавляет). Секрет намеренно СВОЙ (``ALERT_ACK_GLITCHTIP_SECRET``), а не общий
с продуктовым ``TRADEIN_INTERNAL_AUTH_SECRET`` секреты разных хостов/доменов
безопасности компрометировать вместе незачем. Сообщение уходит в ту же тему
клиентских инцидентов (``METRICS_TELEGRAM_CHAT_ID``/``_TOPIC_ID``), что и
Alertmanager-алерты через alert-ack, с явной пометкой «резервный канал», чтобы
не спутать с основным путём.
"""
from __future__ import annotations
@ -51,6 +73,7 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "")
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
API = "https://api.telegram.org/bot{}/{}"
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
@ -153,6 +176,79 @@ def _send_alert(payload: dict) -> None:
_tg("sendMessage", msg)
_GLITCHTIP_TEXT_LIMIT = 3500 # запас под баннер+имя проекта до лимита Telegram 4096
def _verify_glitchtip_secret(provided: str) -> bool:
"""Constant-time сравнение — длина/префикс секрета не утекают через время
ответа (тот же приём, что у продуктового приёмника, см. docstring модуля)."""
return bool(GLITCHTIP_SECRET) and secrets.compare_digest(provided or "", GLITCHTIP_SECRET)
def _glitchtip_field(attachment: dict, label: str) -> str | None:
for field in attachment.get("fields") or []:
if not isinstance(field, dict):
continue
if str(field.get("title", "")).strip().lower() == label.lower():
value = field.get("value")
return str(value) if value is not None else None
return None
def _render_glitchtip(payload: dict) -> str:
"""Собрать текст сообщения из Slack-совместимого payload GlitchTip.
Максимально терпимо к форме тела: GlitchTip шлёт ОДИНАКОВУЮ структуру для
issue- и uptime-алертов, но поля внутри attachments опциональны, а тестовое
сообщение из UI GlitchTip может не иметь attachments вовсе. Ничего в теле
не считаем обязательным падать сервису на резервном канале нельзя.
"""
lines = ["⚠️ РЕЗЕРВНЫЙ КАНАЛ (GlitchTip → alert-ack)"]
lines.append("Основной путь через продуктовый бэкенд мог быть недоступен.")
lines.append("")
text = payload.get("text")
lines.append(html.escape(str(text)) if text else "GlitchTip alert")
attachments = payload.get("attachments")
for attachment in attachments if isinstance(attachments, list) else []:
if not isinstance(attachment, dict):
continue
block: list[str] = []
project = _glitchtip_field(attachment, "Project")
if project:
block.append(f"Проект: {html.escape(project)}")
if attachment.get("title"):
block.append(html.escape(str(attachment["title"])))
if attachment.get("text"):
block.append(html.escape(str(attachment["text"])))
if attachment.get("title_link"):
block.append(f"Ссылка: {html.escape(str(attachment['title_link']))}")
if block:
lines.append("")
lines.extend(block)
out = "\n".join(lines)
if len(out) > _GLITCHTIP_TEXT_LIMIT:
out = out[:_GLITCHTIP_TEXT_LIMIT] + "\n… (обрезано)"
return out
def _send_glitchtip_alert(payload: dict) -> None:
"""Переслать вебхук GlitchTip в ту же тему клиентских инцидентов, что и
Alertmanager через этот сервис. Без кнопки подтверждения это не
firing/resolved инцидент с состоянием, а разовое уведомление резервного
канала."""
msg = {
"chat_id": CHAT_ID,
"text": _render_glitchtip(payload),
"parse_mode": "HTML",
"disable_web_page_preview": "true",
}
if TOPIC_ID:
msg["message_thread_id"] = TOPIC_ID
_tg("sendMessage", msg)
_PAGE = (
"<!doctype html><meta charset=utf-8>"
"<title>{t}</title>"
@ -243,11 +339,24 @@ class Handler(BaseHTTPRequestHandler):
self._reply(code, page.encode())
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
if self.path != "/alertmanager":
self._reply(404, b"not found", "text/plain; charset=utf-8")
return
# Тело читается ДО любой развилки и ветки отказа. protocol_version =
# HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами
# держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит
# оставить его в сокете — и следующий запрос по тому же соединению
# начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный
# зонд на /glitchtip, а следом законный алерт получил
# 501 Unsupported method ('{"text":"probe"}POST'). То есть один
# отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот
# резервный канал и заводился.
parsed = urllib.parse.urlsplit(self.path)
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
if parsed.path == "/glitchtip":
self._handle_glitchtip(parsed, raw)
return
if parsed.path != "/alertmanager":
self._reply(404, b"not found", "text/plain; charset=utf-8")
return
try:
payload = json.loads(raw.decode() or "{}")
except Exception: # noqa: BLE001
@ -261,6 +370,36 @@ class Handler(BaseHTTPRequestHandler):
self._reply(200, b"accepted", "text/plain; charset=utf-8")
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None:
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
Секрет из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
в access-log) либо из query ``?secret=`` (fallback: GlitchTip 6.1.6
заголовков не шлёт вовсе). Несконфигурированный секрет 503, а не
тихий приём без проверки. Неразобранное/нестандартное тело НЕ роняет
запрос это резервный канал, теряться на кривом JSON ему нельзя.
"""
if not GLITCHTIP_SECRET:
self._reply(503, b"glitchtip webhook not configured", "text/plain; charset=utf-8")
return
header_secret = self.headers.get("X-GlitchTip-Secret", "")
query_secret = urllib.parse.parse_qs(parsed.query).get("secret", [""])[0]
if not _verify_glitchtip_secret(header_secret or query_secret):
log.warning("glitchtip webhook: invalid or missing secret")
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
return
try:
payload = json.loads(raw.decode() or "{}")
if not isinstance(payload, dict):
payload = {"text": raw.decode(errors="replace")}
except Exception: # noqa: BLE001 — резервный канал не роняем на кривом теле
payload = {"text": raw.decode(errors="replace")}
self._reply(200, b"accepted", "text/plain; charset=utf-8")
threading.Thread(target=_send_glitchtip_alert, args=(payload,), daemon=True).start()
def main() -> None:
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v]
@ -268,6 +407,11 @@ def main() -> None:
raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
if not PUBLIC_URL:
log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения")
if not GLITCHTIP_SECRET:
log.warning(
"ALERT_ACK_GLITCHTIP_SECRET пуст — резервный канал GlitchTip (#3471) "
"отключён, POST /glitchtip будет отвечать 503"
)
port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "", ONCALL or "")
ThreadingHTTPServer(("", port), Handler).serve_forever()

View file

@ -0,0 +1,256 @@
"""Тесты для резервного канала GlitchTip → alert-ack (#3471).
Продукт (tradein-backend на Selectel) САМ объект наблюдения GlitchTip. Если
он лежит, основной вебхук-получатель лежит вместе с ним, и алерт об этом не
доходит именно тогда, когда нужнее всего. `POST /glitchtip` второй
получатель на ДРУГОМ хосте (Beget, рядом с этим сервисом), не зависящий от
здоровья продукта.
Проверяем на уровне функций, а не полного HTTP-транспорта (тот же приём, что
`ops/glitchtip-auth-forwarder/test_forwarder.py`): `BaseHTTPRequestHandler`
неудобно поднимать без реального сокета, а бизнес-логика секрет, рендер,
отправка целиком вынесена в чистые функции модуля.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
# Env — ДО импорта app.py: BOT_TOKEN/CHAT_ID читаются на уровне модуля.
os.environ.setdefault("METRICS_TELEGRAM_BOT_TOKEN", "test-bot-token")
os.environ.setdefault("METRICS_TELEGRAM_CHAT_ID", "-1001234567890")
os.environ.setdefault("METRICS_TELEGRAM_TOPIC_ID", "158")
os.environ["ALERT_ACK_GLITCHTIP_SECRET"] = "correct-secret"
import app as alert_ack # noqa: E402
def test_verify_glitchtip_secret_accepts_correct_value() -> None:
assert alert_ack._verify_glitchtip_secret("correct-secret") is True
def test_verify_glitchtip_secret_rejects_wrong_value() -> None:
"""Неверный секрет — отказ."""
assert alert_ack._verify_glitchtip_secret("wrong-secret") is False
def test_verify_glitchtip_secret_rejects_empty_value() -> None:
assert alert_ack._verify_glitchtip_secret("") is False
def test_verify_glitchtip_secret_fails_closed_when_unconfigured(monkeypatch) -> None:
"""Пустой ALERT_ACK_GLITCHTIP_SECRET — отказ всем, а не тихий fail-open."""
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
assert alert_ack._verify_glitchtip_secret("correct-secret") is False
assert alert_ack._verify_glitchtip_secret("") is False
def test_render_glitchtip_full_payload_marks_fallback_channel() -> None:
payload = {
"text": "GlitchTip Alert: Something broke",
"attachments": [
{
"title": "TypeError: boom",
"title_link": "https://errors.gendsgn.ru/issue/1",
"text": "подробности ошибки",
"color": "#ff0000",
"fields": [{"title": "Project", "value": "tradein-backend"}],
}
],
}
text = alert_ack._render_glitchtip(payload)
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "Проект: tradein-backend" in text
assert "TypeError: boom" in text
assert "https://errors.gendsgn.ru/issue/1" in text
def test_render_glitchtip_survives_payload_without_attachments() -> None:
"""Тело без attachments не роняет сервис — только текст."""
text = alert_ack._render_glitchtip({"text": "просто текст без вложений"})
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "просто текст без вложений" in text
def test_render_glitchtip_survives_empty_payload() -> None:
"""Пустой словарь (нет ни text, ни attachments) — тоже не должен падать."""
text = alert_ack._render_glitchtip({})
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "GlitchTip alert" in text
def test_render_glitchtip_survives_malformed_attachments() -> None:
"""attachments/fields неожиданной формы (не список, не словарь, битые
типы) резервный канал не должен падать на кривом теле."""
payload = {
"text": "test",
"attachments": [
"not-a-dict",
{"fields": "not-a-list"},
{"fields": [{"title": "Project"}]}, # value отсутствует
None,
],
}
text = alert_ack._render_glitchtip(payload)
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
"""Верный секрет уже проверен вызывающей стороной (do_POST) — здесь
проверяем, что отрендеренное сообщение реально уходит в тот же chat/topic,
что и Alertmanager-алерты этого сервиса, без кнопки подтверждения."""
calls = []
monkeypatch.setattr(alert_ack, "_tg", lambda method, payload: calls.append((method, payload)))
alert_ack._send_glitchtip_alert({"text": "boom", "attachments": []})
assert len(calls) == 1
method, sent = calls[0]
assert method == "sendMessage"
assert sent["chat_id"] == alert_ack.CHAT_ID
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
assert "reply_markup" not in sent
assert "boom" in sent["text"]
# ── Keep-alive: отказ не должен ронять СЛЕДУЮЩИЙ запрос ──────────────────────
# Эти четыре теста — единственные, что поднимают настоящий сокет. Дефект,
# который они стерегут, живёт именно в транспорте и на уровне функций невидим:
# ветка отказа отвечала, не вычитав тело запроса, а `protocol_version` здесь
# HTTP/1.1, то есть соединение переиспользуется (и Caddy перед сервисом держит
# пул к апстриму). Непрочитанное тело оставалось в сокете, и следующий запрос
# по тому же соединению начинался с чужих байт.
#
# Поймано на проде 12.09.2026: зонд без секрета получил 401, а следующий —
# уже с верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
# То есть один отказ съедал следующий НАСТОЯЩИЙ алерт.
def _serve_in_background(monkeypatch):
"""Поднимает Handler на эфемерном порту, глушит отправку в Telegram."""
import threading as _threading
from http.server import ThreadingHTTPServer
sent: list[dict] = []
monkeypatch.setattr(alert_ack, "_send_glitchtip_alert", sent.append)
monkeypatch.setattr(alert_ack, "_send_alert", sent.append)
srv = ThreadingHTTPServer(("127.0.0.1", 0), alert_ack.Handler)
thread = _threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
return srv, sent
def _raw_post(sock, path: str, body: bytes, headers: str = "") -> str:
"""Шлёт POST по уже открытому сокету и возвращает статусную строку."""
req = (
f"POST {path} HTTP/1.1\r\n"
f"Host: localhost\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
f"{headers}"
f"\r\n"
).encode() + body
sock.sendall(req)
# Читаем ровно заголовки: тела короткие, Content-Length всегда проставлен.
buf = b""
while b"\r\n\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
head, _, rest = buf.partition(b"\r\n\r\n")
length = 0
for line in head.split(b"\r\n")[1:]:
if line.lower().startswith(b"content-length:"):
length = int(line.split(b":")[1])
while len(rest) < length:
rest += sock.recv(4096)
return head.split(b"\r\n")[0].decode()
def _pair_on_one_connection(monkeypatch, first_headers: str, first_path: str = "/glitchtip"):
import socket
srv, sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, first_path, b'{"text":"probe"}', first_headers)
second = _raw_post(
sock,
"/glitchtip",
b'{"text":"real alert"}',
"X-GlitchTip-Secret: correct-secret\r\n",
)
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
return first, second, sent
def test_rejected_request_does_not_break_next_one_on_same_connection(monkeypatch) -> None:
"""401 без секрета, следом законный алерт по ТОМУ ЖЕ соединению — 200."""
first, second, sent = _pair_on_one_connection(monkeypatch, "")
assert "401" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]
def test_unconfigured_secret_does_not_break_next_request(monkeypatch) -> None:
"""503 при пустом секрете тоже обязан вычитать тело."""
import socket
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
srv, _sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, "/glitchtip", b'{"text":"probe"}')
second = _raw_post(sock, "/glitchtip", b'{"text":"again"}')
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
assert "503" in first
assert "503" in second, f"второй запрос испорчен первым: {second}"
def test_unknown_path_does_not_break_next_request(monkeypatch) -> None:
"""404 на чужом пути — та же ветка раннего ответа, то же требование."""
first, second, sent = _pair_on_one_connection(monkeypatch, "", first_path="/nope")
assert "404" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]
def test_bad_json_on_alertmanager_does_not_break_next_request(monkeypatch) -> None:
"""400 на неразобранном теле /alertmanager — тело уже вычитано, связь цела."""
import socket
srv, sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, "/alertmanager", b"{not json")
second = _raw_post(
sock,
"/glitchtip",
b'{"text":"real alert"}',
"X-GlitchTip-Secret: correct-secret\r\n",
)
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
assert "400" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]

View file

@ -71,9 +71,16 @@ route:
repeat_interval: 3h
inhibit_rules:
# Если хост целиком недоступен, не сыпать отдельно про каждый его сервис.
# Если хост целиком недоступен, не сыпать отдельно про каждый его
# warning-сервис. `target_matchers` НАМЕРЕННО ограничен одним warning
# (#3471): раньше сюда попадал и critical, и падение node-exporter молча
# гасило заодно PostgresLongTransactionCritical и все critical cAdvisor-
# алерты того же хоста — самое важное сообщение исчезало вместе с шумом,
# который оно должно было подавить. Warning того же хоста подавлять по-
# прежнему стоит (диск/память/своп неотличимы от «нет данных»), а critical
# обязан пережить это подавление и дойти до дежурного отдельно.
- source_matchers: [alertname = "HostAgentDown"]
target_matchers: [severity =~ "warning|critical"]
target_matchers: [severity = "warning"]
equal: ["host"]
receivers:

View file

@ -119,6 +119,27 @@ prometheus.scrape "postgres" {
scrape_interval = "60s"
}
// ═══ REDIS И ОЧЕРЕДЬ CELERY (#3471) ═════════════════════════════════════════════
// До этой правки ни одной серии redis_* / celery_* в Prometheus не было: глубина
// очереди, число живых воркеров и потеря соединения с брокером были невидимы —
// переполнение очереди и залипший воркер снаружи выглядели одинаково, тишиной.
prometheus.scrape "redis" {
targets = [
{ __address__ = "gendesign-redis-exporter:9121", job = "redis" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
}
prometheus.scrape "celery" {
targets = [
{ __address__ = "gendesign-celery-exporter:9808", job = "celery" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
}
// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════
// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
// правильно: цель видна как недоступная, а не отсутствует молча.

View file

@ -57,9 +57,13 @@ prometheus.scrape "cadvisor" {
prometheus.relabel "cadvisor_trim" {
forward_to = [prometheus.remote_write.central.receiver]
// `up`/`scrape_samples_scraped` — служебные ряды самого скрейпа, не
// container_*-метрики. Без явного допуска этот keep-фильтр резал их вместе
// с прочим шумом, и у cAdvisor как job'а не было своей серии `up` вообще —
// его смерть выглядела так же, как «ничего не изменилось» (#3471).
rule {
source_labels = ["__name__"]
regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
regex = "up|scrape_samples_scraped|container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
action = "keep"
}
@ -70,9 +74,15 @@ prometheus.relabel "cadvisor_trim" {
//
// NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на
// Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1.
//
// `up`/`scrape_samples_scraped` лейбла `name` не несут вовсе (они не про
// конкретный контейнер, а про сам скрейп) — фильтр по нему вырезал бы и их.
// Поэтому здесь смотрим на пару (__name__, name): для служебных рядов
// достаточно самого __name__, для контейнерных метрик по-прежнему обязателен
// непустой name.
rule {
source_labels = ["name"]
regex = ".+"
source_labels = ["__name__", "name"]
regex = "up;.*|scrape_samples_scraped;.*|container_[^;]*;.+"
action = "keep"
}
}

View file

@ -107,7 +107,7 @@
{
"type": "timeseries",
"title": "Запросы по классам ответов",
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка.",
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка. Линии НЕ стекируются: высота красной линии — это и есть число пятисоток, а не сумма со всем, что под ней.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
"targets": [
@ -117,7 +117,7 @@
{ "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" }
],
"fieldConfig": {
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } },
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] },
{ "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] },

View file

@ -0,0 +1,161 @@
{
"uid": "gendesign-product",
"title": "Продуктовые метрики",
"description": "Числа бизнеса, а не процесса: сколько людей реально что-то сделали в «Мере» и «Птице» (#3471). Источник — те же счётчики Prometheus, что и на дашборде «Приложения», только считают не HTTP-статусы, а продуктовые события (оценка, лид, отчёт, вход, обращение в поддержку). Часовое окно нарочно грубое: при текущем трафике (сотни событий в сутки) минутные всплески — шум, а не сигнал.",
"tags": ["gendesign", "product"],
"timezone": "browser",
"editable": false,
"schemaVersion": 39,
"refresh": "5m",
"time": { "from": "now-24h", "to": "now" },
"panels": [
{ "type": "row", "title": "Мера — воронка продукта", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
{
"type": "timeseries",
"title": "Оценок в час, по исходу",
"description": "insufficient_data — не ошибка: аналогов рядом с адресом не нашлось, расчёт прошёл штатно. Тревожиться стоит, если эта линия начинает расти быстрее ok — значит покрытие рынка проседает, а не то, что сломался расчёт.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "sum by (outcome) (increase(mera_estimates_total[1h]))",
"legendFormat": "{{outcome}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "insufficient_data" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
{ "matcher": { "id": "byName", "options": "ok" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Подсказки адреса в час, нашёлся ли результат",
"description": "found=no — человек напечатал адрес, а автокомплит ничего не предложил: либо адреса нет в базе, либо он вне зоны покрытия. Устойчивый рост этой линии — повод расширять покрытие, а не баг одного запроса.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "sum by (found) (increase(mera_address_suggestions_total[1h]))",
"legendFormat": "найден: {{found}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*no.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Лиды и скачанные отчёты в час",
"description": "Лид — заявка с телефоном после оценки, отчёт — скачанный PDF по оценке. Оба редкие: сверяйте с недельным окном (кнопка времени вверху), часовой провал сам по себе ни о чём не говорит при таком трафике.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
"targets": [
{ "refId": "A", "expr": "increase(mera_leads_total[1h])", "legendFormat": "лиды" },
{ "refId": "B", "expr": "increase(mera_reports_exported_total[1h])", "legendFormat": "отчёты" }
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Входы в час, по исходу",
"description": "Устойчивый рост failed при ровном success — либо перебор паролей, либо сломался клиент (истёкшая сессия, старый билд фронта). При подозрении на перебор смотрите заодно панель «Отказы авторизации и лимитера» на дашборде «Приложения» — там 401/429 по HTTP.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
"targets": [
{
"refId": "A",
"expr": "sum by (result) (increase(mera_logins_total[1h]))",
"legendFormat": "{{result}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] },
{ "matcher": { "id": "byName", "options": "success" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Обращения в поддержку в час, по каналу",
"description": "anon — обращения с экрана входа, без логина (типично «не могу войти»), web — уже залогиненные пользователи. Всплеск anon без роста web — обычно означает баг именно в форме/процессе входа, а не общий рост нагрузки на поддержку.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
"targets": [
{
"refId": "A",
"expr": "sum by (channel) (increase(mera_support_messages_total[1h]))",
"legendFormat": "{{channel}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "anon" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
]
}
},
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
{
"type": "timeseries",
"title": "Экспортов отчётов по участку в час, по формату",
"description": "Формат — то, что реально скачали: md/json/docx/pptx/pdf/tg-сводка §22-форсайта плюс best_layouts_pdf (ТЗ на проектирование). Провал всех форматов разом при живом трафике на дашборде «Приложения» значит, что сломан сам экспорт, а не рендер одного конкретного формата.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 26 },
"targets": [
{
"refId": "A",
"expr": "sum by (format) (increase(sitefinder_reports_exported_total[1h]))",
"legendFormat": "{{format}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
}
]
}

View file

@ -0,0 +1,16 @@
# Эта папка сознательно пустая
Grafana умеет провижинить contact points, notification policies и alert rules
файлами отсюда (`/etc/grafana/provisioning/alerting`). Не клади их сюда.
Решение (#3158, 12.09.2026): единственный путь доставки тревог — Prometheus
(правила) + Alertmanager (маршрутизация, Telegram). Grafana только рисует.
Встроенный Alerting выключен явно (`GF_UNIFIED_ALERTING_ENABLED: "false"` в
`docker-compose.metrics.yml`, секция `grafana`) — при живом API 12.09.2026
единственным контакт-поинтом был стоковый `grafana-default-email` на
`example@email.com`, `GF_SMTP_*` не задан, правил ноль. Файл сюда работать не
заставит: движок alerting выключен на уровне сервиса, провижининг в эту папку
Grafana просто не читает.
Если понадобится включить обратно — сначала пересмотреть само решение в
`docker-compose.metrics.yml`, а не просто добавить файл в эту папку.

View file

@ -44,6 +44,21 @@ groups:
summary: "С продуктового хоста 15 минут не приходят метрики"
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
# cAdvisor как job не публикует `up` естественным образом — до фикса
# keep-фильтра в alloy-infra.alloy/alloy-apps.alloy (#3471) эта серия
# вырезалась тем же правилом, что чистит container_* от мусорных
# лейблов. Итог — смерть cAdvisor молча гасила ContainerRestartLoop и
# ContainerNearMemoryLimit: обе метрики просто переставали поступать, а
# выглядело это как «событий не было».
- alert: CadvisorDown
expr: up{job="cadvisor"} == 0 or absent(up{job="cadvisor"})
for: 5m
labels:
severity: critical
annotations:
summary: "cAdvisor не отвечает"
description: "{{ if $labels.host }}{{ $labels.host }}: {{ end }}job=\"cadvisor\" вернул up=0 либо серия пропала целиком. Без неё контейнерные алерты этого хоста молчат вне зависимости от реального состояния контейнеров."
# ── Хост ────────────────────────────────────────────────────────────────────
- name: host
interval: 60s
@ -125,11 +140,19 @@ groups:
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
#
# ФОРМА ВЫРАЖЕНИЯ ВАЖНА, а не только условие. `A and B` возвращает ЗНАЧЕНИЯ
# ЛЕВОЙ части, отфильтрованные правой, — то есть в `$value` попадает именно
# A. Прежняя запись (`limit > 0 and working_set/limit > 0.90`) слала в
# Telegram лимит В БАЙТАХ, отрендеренный как процент: боевое сообщение
# 12.09 — «2.684e+11% от mem_limit» при limit = 2 684 354 560 Б. Условие
# при этом срабатывало верно, врал только текст. Поэтому отношение стоит
# СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)`
# выбрасывает серии без лимита ДО деления.
- alert: ContainerNearMemoryLimit
expr: |
container_spec_memory_limit_bytes{name!=""} > 0
and container_memory_working_set_bytes{name!=""}
/ container_spec_memory_limit_bytes{name!=""} > 0.90
container_memory_working_set_bytes{name!=""}
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
for: 15m
labels:
severity: warning
@ -137,6 +160,135 @@ groups:
summary: "Контейнер у своего потолка памяти"
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
# tradein-tgbot и tradein-scraper не HTTP-сервисы — у них нет `up{}`
# вообще, поэтому крэш-без-рестарта или удаление контейнера иначе не
# поймать. `absent()` на каждое имя отдельно (не одним regex-селектором):
# regex-селектор с несколькими сериями считается «пустым» только когда
# ПРОПАЛИ ОБЕ — если жив хотя бы один из двух контейнеров, absent() по
# общему selector'у молчит и не заметит пропажу второго.
#
# ВАЖНО, чего это правило НЕ ловит: container_last_seen обновляется, пока
# Docker видит контейнер живым, — зависший, но не упавший процесс
# (внутренний цикл встал, контейнер по-прежнему числится running) эту
# метрику не тронет. Слепая зона «живой процесс с застрявшим циклом»
# остаётся открытой: подходящей метрики для неё сейчас нет.
- alert: TradeInBackgroundContainerMissing
expr: |
absent(container_last_seen{name="tradein-tgbot"})
or absent(container_last_seen{name="tradein-scraper"})
for: 5m
labels:
severity: critical
annotations:
summary: "Фоновый контейнер Меры пропал из cAdvisor"
description: "{{ $labels.name }}: серия container_last_seen исчезла — контейнер, судя по всему, не работает и не перезапускается."
# ── Приложение ──────────────────────────────────────────────────────────────
# `job="app"` — job из alloy-apps.alloy, лейбл `app` различает продукты
# (sitefinder / mera). Метрики отдаёт `MetricsMiddleware`
# (`backend/app/observability/metrics.py` у «Птицы»,
# `tradein-mvp/backend/app/observability/metrics.py` у «Меры») — счётчик
# `http_requests_total{method,route,status}` и гистограмма
# `http_request_duration_seconds{method,route}`. До этой группы доля 5xx и
# задержка были видны только постфактум в GlitchTip, без порога срабатывания
# (#3471).
#
# severity: critical + host: apps здесь ОБЯЗАТЕЛЬНЫ содержательно, не для
# красоты: именно эта пара матчится маршрутом telegram-clients в
# alertmanager.yml.tmpl — тот зовёт дежурного и напоминает каждые 30 минут.
# `host` выставлен статически: `sum by (app)` вырезает его из результата
# запроса, а job="app" в принципе существует только на продуктовом хосте.
- name: app
interval: 60s
rules:
# Гейт по RPS внутри знаменателя — тот же приём, что у
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
# дежурного зря.
- alert: AppHighErrorRate
expr: |
sum by (app) (rate(http_requests_total{job="app", status=~"5.."}[5m]))
/ (sum by (app) (rate(http_requests_total{job="app"}[5m])) > 0.1) > 0.05
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Доля 5xx выше 5%"
description: "{{ $labels.app }}: {{ $value | humanizePercentage }} ответов 5xx за последние 5 минут при RPS выше 0.1."
# Порог 5s — заведомо выше рабочего профиля обоих продуктов (у «Меры»
# типичный расчёт 90мс, у «Птицы» верхняя граница гистограммы — 60с под
# тяжёлую геометрию, но это единичные хвостовые запросы, не p95).
# Калибровка по реальному трафику — отдельная задача, не эта.
- alert: AppHighLatencyP95
expr: |
histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{job="app"}[10m]))) > 5
for: 10m
labels:
severity: critical
host: apps
annotations:
summary: "p95 задержки ответа выше 5 секунд"
description: "{{ $labels.app }}: p95 за 10 минут — {{ $value | humanizeDuration }}."
# ── Redis и очередь Celery (#3471) ───────────────────────────────────────────
# Слепая зона: до этих правил ни redis_*, ни celery_* не собирались вовсе.
# Redis — общий инстанс на три потребителя (celery-брокер Site Finder, кэш
# trade-in, glitchtip — см. docker-compose.prod.yml), поэтому его смерть
# клиентская, отсюда severity: critical без явного host: apps — серия
# приходит только с продуктового alloy (alloy-apps.alloy), host в неё
# проставляется через external_labels уже на месте.
#
# ⚠️ Имена метрик celery_queue_length / celery_worker_up /
# celery_task_failed_total — по документации celery-exporter на момент
# написания правил, без проверки на реальном брокере (см. комментарий у
# сервиса celery-exporter в docker-compose.metrics-agent.yml). Сверить после
# первого деплоя.
- name: redis-celery
interval: 60s
rules:
- alert: RedisDown
expr: up{job="redis"} == 0 or redis_up == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Redis недоступен"
description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip."
# `absent()` — как у CadvisorDown: если сам celery-exporter не поднялся,
# серии celery_worker_up не будет вообще, а не будет со значением 0.
- alert: NoActiveCeleryWorkers
expr: count(celery_worker_up == 1) == 0 or absent(celery_worker_up)
for: 5m
labels:
severity: critical
annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# Порог 150 ПРЕДВАРИТЕЛЬНЫЙ: реальных данных по глубине очереди нет (до
# этой правки метрика не собиралась). beat_schedule.py на момент правки
# содержит 44 periodic-задачи с разным временем срабатывания — даже
# маловероятный залп всех разом даёт кратно меньше 150. Порог взят с
# запасом сознательно и требует пересмотра через неделю наблюдений по
# факту `celery_queue_length`.
#
# `delta(...) >= 0` — очередь не УМЕНЬШАЕТСЯ за 15 минут (тот же приём,
# что и "растёт и не разгребается" в тексте задачи): просто высокое
# значение без этого условия поймало бы и здоровый кратковременный всплеск.
- alert: CeleryQueueGrowing
expr: |
celery_queue_length{queue_name="celery"} > 150
and delta(celery_queue_length{queue_name="celery"}[15m]) >= 0
for: 15m
labels:
severity: warning
annotations:
summary: "Очередь Celery растёт и не разгребается"
description: "В очереди {{ $value }} задач, за 15 минут меньше не стало. Похоже на залипший воркер или устойчивый рост нагрузки."
# ── Postgres ────────────────────────────────────────────────────────────────
- name: postgres
interval: 60s
@ -172,12 +324,20 @@ groups:
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
# позволило 91 день не замечать 198 апдейтов на строку.
#
# Та же ловушка `A and B`, что и у ContainerNearMemoryLimit, и здесь она
# опаснее: в `$value` попадал `rate(tup_upd[6h])` — АПДЕЙТОВ В СЕКУНДУ, а
# текст называл это долей HOT. Боевое сообщение 12.09 — «доля HOT 75.21%»
# при пороге срабатывания «доля < 20%»: число само себе противоречило и
# выглядело правдоподобно, поэтому никто не заметил (замер 12.09 по той же
# таблице listings: rate(tup_upd[6h]) = 0.0411 → сообщение сказало бы
# «4.11%», настоящая доля HOT = 0.00%). Гейт по объёму апдейтов
# (> 0.5/с — «трафик есть, значит вопрос осмыслен») перенесён внутрь
# знаменателя: там он и фильтрует серии, и защищает от деления на ноль.
- alert: PostgresLowHotUpdateRatio
expr: |
rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
and
rate(pg_table_write_amplification_tup_hot_upd[6h])
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5) < 0.2
for: 6h
labels:
severity: warning
@ -185,6 +345,11 @@ groups:
summary: "Обновления идут мимо HOT"
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
# Третье правило того же семейства `A and B` — и единственное, где текст
# верен: `$value` тут печатается без humanize, а слева стоит ровно то, что
# описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если
# когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести
# влево, как в двух правилах выше.
- alert: PostgresDeadTuplesHigh
expr: |
pg_table_write_amplification_dead_tup > 1000000

162
ops/metrics/tg-relay/app.py Normal file
View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Ретранслятор Bot API продукта через инфраструктурный хост Beget (#3471).
ЗАЧЕМ. Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
`tradein-tgbot` на Selectel 9 успешных из 12, три `ConnectTimeout`. TCP на 443
до адреса, резолвящегося на Selectel (149.154.167.220) 5 из 6. Тот же TCP до
адреса, резолвящегося на Beget (149.154.166.110) 8 из 8. За сутки в логе бота
508 строк `network error`, за 30 дней 92 обрыва итерации poll loop. Путь до
Telegram с Selectel лоссовый, с Beget чистый: Alertmanager (живёт на Beget)
пишет в тот же чат без проблем, а бот поддержки на Selectel часть отправок
теряет. Решение не чинить сеть Selectel (вне контроля), а дать продуктовым
сервисам обходной путь через хост, с которого Telegram отвечает надёжно.
ЧТО ПРОКСИРУЕТСЯ. Метод Bot API целиком путь `/bot<TOKEN>/<method>`, тело,
query. Не только отправка: `getUpdates` (long-poll) страдает от потерь ровно
так же, как `sendMessage`, и это тот же HTTP-путь через тот же испорченный
транзит.
ТОКЕН НЕ ЛОГИРУЕТСЯ. Он приходит в пути запроса. `log_request` переопределён
ПОЛНОСТЬЮ (не вызывает `super()`): дефолт stdlib кладёт в лог `requestline`
целиком, включая токен. Здесь путь редактируется до записи в лог.
АУТЕНТИФИКАЦИЯ. Общий секрет в заголовке `X-Relay-Secret`, по образцу общего
секрета `X-Internal-Auth-Secret` в этом же стеке (`app/core/config.py`,
`tradein_internal_auth_secret`) сравнение строкой (не сравнение таймингов:
секрет не является паролем пользователя, ценность атаки по времени здесь
исчезающе мала при секрете длиной от 32 байт, а stdlib `hmac` лишняя
зависимость ради stdlib-only сервиса). Домен публичный, без секрета отказ,
а не тихий приём.
БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека тот же принцип, что у
`ops/metrics/alert-ack/app.py`: сервис обязан подниматься, даже когда всё
остальное сломано, и не тащить установку пакетов.
ОТКАЗ РЕТРАНСЛЯТОРА НЕ ДОЛЖЕН РОНЯТЬ БОТА. Это реализовано НЕ здесь, а на
стороне клиента (`tradein-mvp/backend/app/services/tgbot/client.py`): при
транспортном отказе похода на ретранслятор клиент делает одну попытку
напрямую к `api.telegram.org`. Здесь достаточно не быть точкой отказа хуже
прямого пути: таймаут до апстрима подобран так, чтобы не обрубать long-poll
`getUpdates` раньше, чем это сделал бы сам Telegram.
Переменные окружения:
TG_RELAY_SECRET обязательна общий секрет, сверяется с
заголовком X-Relay-Secret
TG_RELAY_PORT порт (по умолчанию 8080)
TG_RELAY_UPSTREAM_TIMEOUT_S таймаут запроса к api.telegram.org в секундах
(по умолчанию 75 с запасом над самым долгим
long-poll getUpdates, который шлёт клиент:
timeout=30 + 10с запаса на стороне httpx = 40с)
"""
from __future__ import annotations
import logging
import os
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("tg-relay")
RELAY_SECRET = os.environ.get("TG_RELAY_SECRET", "")
UPSTREAM = "https://api.telegram.org"
UPSTREAM_TIMEOUT_S = float(os.environ.get("TG_RELAY_UPSTREAM_TIMEOUT_S", "75"))
SECRET_HEADER = "X-Relay-Secret"
def redact_path(path: str) -> str:
"""Прячет токен из `/bot<TOKEN>/method[?query]` для логов и ошибок.
Вынесена в модульную функцию (не метод), чтобы быть проверяемой напрямую
без поднятия HTTP-сервера.
"""
if not path.startswith("/bot"):
return path
rest = path[len("/bot") :]
_token_part, sep, tail = rest.partition("/")
if not sep:
# Ни `/method`, ни query — токен без хвоста (или токен+query без slash).
return "/bot<REDACTED>"
tail = tail.split("?", 1)[0]
return f"/bot<REDACTED>/{tail}" if tail else "/bot<REDACTED>"
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — сигнатура из stdlib
log.info("%s", fmt % args)
def log_request(self, code="-", size="-") -> None: # noqa: A003 — сигнатура из stdlib
# ПОЛНОСТЬЮ заменяет реализацию BaseHTTPRequestHandler (не вызывает
# super()): та кладёт в лог self.requestline целиком, а он для Bot API
# содержит токен в пути.
log.info('%s "%s %s" %s', self.address_string(), self.command, redact_path(self.path), code)
def _reply(self, code: int, body: bytes, ctype: str = "application/json") -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def _authorized(self) -> bool:
return bool(RELAY_SECRET) and self.headers.get(SECRET_HEADER) == RELAY_SECRET
def _proxy(self) -> None:
if self.path == "/healthz":
self._reply(200, b"ok", "text/plain; charset=utf-8")
return
if not self._authorized():
self._reply(401, b'{"ok":false,"description":"unauthorized"}')
return
if not self.path.startswith("/bot"):
self._reply(404, b'{"ok":false,"description":"not found"}')
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else None
req = urllib.request.Request(
UPSTREAM + self.path,
data=body,
method=self.command,
headers={"Content-Type": self.headers.get("Content-Type") or "application/json"},
)
try:
# Таймаут ЯВНО шире любого long-poll getUpdates клиента — иначе
# ретранслятор обрубит соединение раньше площадки и превратит
# штатный long-poll в вечный network error, то есть станет хуже
# прямого пути, а не лучше.
with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT_S) as resp:
out = resp.read()
self._reply(resp.status, out, resp.headers.get("Content-Type") or "application/json")
except urllib.error.HTTPError as exc:
# Telegram ответил ошибкой (4xx/5xx) — это НЕ отказ ретранслятора,
# передаём как есть, клиент сам решает, ретраить или нет.
out = exc.read()
ctype = exc.headers.get("Content-Type") if exc.headers else None
self._reply(exc.code, out, ctype or "application/json")
except Exception as exc: # noqa: BLE001 — любой отказ апстрима не должен уронить сервис
log.warning("upstream недоступен (%s): %s", redact_path(self.path), type(exc).__name__)
self._reply(502, b'{"ok":false,"description":"relay upstream unreachable"}')
def do_GET(self) -> None: # noqa: N802 — имя из stdlib
self._proxy()
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
self._proxy()
def main() -> None:
if not RELAY_SECRET:
raise SystemExit("не задан TG_RELAY_SECRET — домен публичный, отказ на старте")
port = int(os.environ.get("TG_RELAY_PORT", "8080"))
log.info("tg-relay слушает :%d, upstream_timeout=%.0fs", port, UPSTREAM_TIMEOUT_S)
ThreadingHTTPServer(("", port), Handler).serve_forever()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,144 @@
"""Тесты для tg-relay — ретранслятора Bot API продукта через Beget (#3471).
Гоняют реальный `ThreadingHTTPServer` на localhost (эфемерный порт), апстрим
`urllib.request.urlopen` подменяется моком реальный api.telegram.org НЕ
дёргаем никогда.
"""
from __future__ import annotations
import http.client
import json
import logging
import threading
from unittest import mock
import pytest
import app as relay
def _fake_upstream_response(status: int = 200, body: bytes = b'{"ok": true, "result": []}'):
resp = mock.MagicMock()
resp.status = status
resp.read.return_value = body
resp.headers.get.return_value = "application/json"
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return resp
@pytest.fixture()
def secret(monkeypatch):
monkeypatch.setattr(relay, "RELAY_SECRET", "test-secret-value")
return "test-secret-value"
@pytest.fixture()
def server(secret):
httpd = relay.ThreadingHTTPServer(("127.0.0.1", 0), relay.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
yield httpd
finally:
httpd.shutdown()
thread.join(timeout=5)
def _request(server, path, headers=None, method="GET", body=None):
conn = http.client.HTTPConnection(*server.server_address, timeout=5)
try:
conn.request(method, path, body=body, headers=headers or {})
resp = conn.getresponse()
return resp.status, resp.read()
finally:
conn.close()
def test_redact_path_hides_token_from_method_path():
assert relay.redact_path("/bot123456:ABC-DEF/sendMessage") == "/bot<REDACTED>/sendMessage"
def test_redact_path_hides_bare_token():
assert relay.redact_path("/bot123456:ABC-DEF/") == "/bot<REDACTED>"
assert relay.redact_path("/bot123456:ABC-DEF") == "/bot<REDACTED>"
def test_redact_path_leaves_non_bot_paths_untouched():
assert relay.redact_path("/healthz") == "/healthz"
def test_missing_secret_rejected_without_touching_upstream(server):
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
status, _body = _request(server, "/bot123:TOK/getMe")
assert status == 401
mocked.assert_not_called()
def test_wrong_secret_rejected_without_touching_upstream(server):
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
status, _body = _request(server, "/bot123:TOK/getMe", headers={"X-Relay-Secret": "wrong"})
assert status == 401
mocked.assert_not_called()
def test_valid_secret_passes_method_path_and_body_unmodified(server, secret):
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
) as mocked:
status, body = _request(
server,
"/bot123:TOK/sendMessage",
headers={"X-Relay-Secret": secret, "Content-Type": "application/json"},
method="POST",
body=b'{"chat_id": 1, "text": "hi"}',
)
assert status == 200
assert json.loads(body) == {"ok": True, "result": []}
sent_request = mocked.call_args[0][0]
assert sent_request.full_url == "https://api.telegram.org/bot123:TOK/sendMessage"
assert sent_request.data == b'{"chat_id": 1, "text": "hi"}'
assert sent_request.get_method() == "POST"
def test_get_updates_uses_upstream_timeout_wider_than_longest_client_poll(server, secret):
"""Клиент шлёт getUpdates(timeout=30) → httpx ждёт ответ 40с (30 + запас
10с). Апстрим-таймаут ретранслятора обязан быть шире, иначе он обрубит
long-poll раньше площадки и превратит штатный цикл в вечный network error."""
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
) as mocked:
_request(
server,
"/bot123:TOK/getUpdates",
headers={"X-Relay-Secret": secret},
method="POST",
body=b'{"offset": 1, "timeout": 30}',
)
_req, kwargs = mocked.call_args
assert kwargs["timeout"] >= 40
def test_token_never_appears_in_logs(server, secret, caplog):
token = "999888777:VerySecretTokenValue"
with caplog.at_level(logging.INFO, logger="tg-relay"):
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
):
_request(server, f"/bot{token}/getMe", headers={"X-Relay-Secret": secret})
for record in caplog.records:
assert token not in record.getMessage()
def test_unauthorized_attempt_does_not_leak_token_either(server, caplog):
token = "999888777:VerySecretTokenValue"
with caplog.at_level(logging.INFO, logger="tg-relay"):
_request(server, f"/bot{token}/getMe")
for record in caplog.records:
assert token not in record.getMessage()

View file

@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Гейт: подмена tradein-frontend не возвращает окно недоступности (#3274).
ПОЧЕМУ. Публичный лендинг meraocenka.ru лежал 3090 с на КАЖДОМ деплое МЕРЫ.
Причина НЕ медленная подмена контейнера (она стоит полсекунды), а то, что
`docker compose up -d` со СПИСКОМ сервисов работает в две фазы: сначала create
(старый контейнер каждого сервиса останавливается и удаляется иначе занято
`container_name`), потом start, в порядке зависимостей и с ожиданием их
условий. Всё, что между фазами, фронт лежит.
Замер на проде 10.09 (docker inspect .Created/.StartedAt, два деплоя подряд):
пачка сервисов: tradein-backend создан 15:01:40 запущен 15:02:10 (30 с)
один сервис: tradein-frontend создан 16:42:17.5 запущен 16:42:18.0 (0,5 с)
В логе Caddy у первого деплоя три 503 на лендинге (15:01:46, 15:01:52,
15:02:06), у второго ни одного.
ЧТО ДЕРЖИТ РЕШЕНИЕ, И ПОЧЕМУ ЭТО ГЕЙТ, А НЕ КОММЕНТАРИЙ. Половинки лежат в
разных файлах и обе невидимо обратимы:
1) deploy-tradein.yml `frontend` ВЫНЕСЕН из общего `up -d $SERVICES` в
свою команду. Достаточно дописать его обратно в SERVICES «за компанию»,
и окно вернётся целиком, молча: деплой останется зелёным.
2) caddy/sites/apps.caddy каждый `reverse_proxy tradein-frontend:3000`
импортирует (tradein_frontend_retry) (lb_try_duration), который добирает
оставшиеся ~0,5 с. Новый публичный путь копируют с соседнего блока и
если копируют блок БЕЗ импорта, именно этот путь снова отдаёт 502.
Оба отказа не видны ни по статусу джобы, ни по глазам: их видно только
непрерывной пробой во время деплоя (scripts/probe-deploy-window.sh).
ЧЕГО НЕ ЛОВИТ. Это разбор текста, а не исполнение: гейт не проверяет, что
команда реально отработала и что Caddy реально ретраит (это проверяется
пробой на живом деплое). Не смотрит на сервисы, кроме frontend, бэкенду
ретрай намеренно не дан (его старт длиннее, чем разумное ожидание клиента).
Запуск: python3 scripts/check-frontend-swap-window.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
WORKFLOW = REPO / ".forgejo" / "workflows" / "deploy-tradein.yml"
APPS_CADDY = REPO / "caddy" / "sites" / "apps.caddy"
SNIPPET_NAME = "tradein_frontend_retry"
FRONTEND_UPSTREAM = "reverse_proxy tradein-frontend:3000 {"
# `SERVICES="browser backend tgbot"` / `SERVICES="$SERVICES scraper"`.
_SERVICES_RE = re.compile(r'^\s*SERVICES=(["\']?)(.*?)\1\s*$', re.M)
# Одиночная команда подмены фронта.
_FRONTEND_UP_RE = re.compile(r"up -d --no-deps frontend\s*$", re.M)
def strip_comments(text: str) -> str:
"""Убирает строки-комментарии (shell/YAML/Caddy — везде `#`).
Обязательно: разбор дефекта живёт в комментарии рядом с правкой и содержит
его же формулировку. Без этого гейт спорил бы с собственным объяснением.
"""
return "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#"))
def check_workflow(text: str) -> list[str]:
body = strip_comments(text)
errors: list[str] = []
for m in _SERVICES_RE.finditer(body):
value = m.group(2)
if re.search(r"(^|\s)frontend(\s|$)", value):
errors.append(
f'SERVICES={value!r} снова содержит `frontend`: он вернётся в общий '
f"`up -d` со списком сервисов, а это и есть окно 3090 с (#3274). "
f"Фронт подменяется отдельной командой `up -d --no-deps frontend`."
)
if not _FRONTEND_UP_RE.search(body):
errors.append(
"в deploy-tradein.yml нет отдельной команды `up -d --no-deps frontend` — "
"подмена фронта либо пропала, либо снова уехала в общую пачку (#3274)."
)
return errors
def check_caddy(text: str) -> list[str]:
errors: list[str] = []
lines = text.splitlines()
body = strip_comments(text)
if f"({SNIPPET_NAME}) {{" not in body:
errors.append(
f"снипет ({SNIPPET_NAME}) не объявлен в apps.caddy — импортировать нечего."
)
elif "lb_try_duration" not in body:
errors.append(
f"снипет ({SNIPPET_NAME}) есть, но без `lb_try_duration` — он больше "
"ничего не добирает, оставшиеся ~0,5 с подмены снова видны как 502."
)
# Блок апстрима фронта: от строки `reverse_proxy tradein-frontend:3000 {`
# до закрывающей скобки на её же отступе. Импорт должен быть внутри.
for i, line in enumerate(lines):
if line.strip().startswith("#") or FRONTEND_UPSTREAM not in line:
continue
indent = len(line) - len(line.lstrip())
block: list[str] = []
for nxt in lines[i + 1 :]:
if nxt.strip() == "}" and (len(nxt) - len(nxt.lstrip())) == indent:
break
block.append(nxt)
if not any(f"import {SNIPPET_NAME}" in b for b in block if not b.strip().startswith("#")):
errors.append(
f"apps.caddy:{i + 1} — `reverse_proxy tradein-frontend:3000` без "
f"`import {SNIPPET_NAME}`: на этом пути подмена контейнера снова "
f"видна посетителю как 502 (#3274)."
)
return errors
def selftest() -> None:
good_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps frontend\n'
assert check_workflow(good_wf) == [], check_workflow(good_wf)
bad_wf = 'SERVICES="browser backend frontend tgbot"\n docker compose up -d --no-deps frontend\n'
assert any("SERVICES" in e for e in check_workflow(bad_wf)), "не поймал frontend в SERVICES"
missing_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps $SERVICES\n'
assert any("отдельной команды" in e for e in check_workflow(missing_wf)), (
"не поймал пропажу отдельной команды"
)
# Комментарий с той же формулировкой не должен ронять гейт.
commented = '# SERVICES="browser backend frontend tgbot" # так было до #3274\n' + good_wf
assert check_workflow(commented) == [], "гейт спорит с собственным комментарием"
good_caddy = (
"(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n"
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" import tradein_frontend_retry\n header_up -X-Y\n }\n}\n"
)
assert check_caddy(good_caddy) == [], check_caddy(good_caddy)
bad_caddy = (
"(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n"
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" header_up -X-Y\n }\n}\n"
)
assert any("без `import" in e for e in check_caddy(bad_caddy)), "не поймал блок без импорта"
no_snippet = (
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" import tradein_frontend_retry\n }\n}\n"
)
assert any("не объявлен" in e for e in check_caddy(no_snippet)), "не поймал пропажу снипета"
print("SELFTEST OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
errors = check_workflow(WORKFLOW.read_text(encoding="utf-8"))
errors += check_caddy(APPS_CADDY.read_text(encoding="utf-8"))
if errors:
print("Гейт #3274 (окно подмены фронта) НЕ ПРОЙДЕН:\n")
for e in errors:
print(f"{e}")
print(
"\nЧем проверять эффект на живом деплое: scripts/probe-deploy-window.sh "
"(запускать НА хосте прода, см. шапку скрипта)."
)
return 1
print("Гейт #3274: фронт подменяется отдельной командой, все 9 апстримов с ретраем — OK")
return 0
if __name__ == "__main__":
sys.exit(main())

99
scripts/probe-deploy-window.sh Executable file
View file

@ -0,0 +1,99 @@
#!/bin/sh
# Проба окна недоступности при деплое (#3274).
#
# ЗАЧЕМ. Статус джобы деплоя зелёный независимо от того, видел ли посетитель
# ошибку: подмена контейнера происходит ВНУТРИ успешного прогона. Единственный
# честный замер — непрерывный опрос публичного адреса во время деплоя и подсчёт
# САМОЙ ДЛИННОЙ СЕРИИ подряд идущих не-200. Приёмка #3274 сформулирована именно
# так (комментарий от 05.09): «серия не-2xx/000 на / в окне мержа < 2 с».
#
# ГДЕ ЗАПУСКАТЬ — НА САМОМ ХОСТЕ (ssh poincare), не с ноутбука. Боевой периметр
# отбивает частые серии с одного внешнего адреса: замер 11.09 дал 3050 %
# `000` с внешнего IP при интервале 0 и ноль при 2 с, а с самого хоста — 0/10
# без пауз. Иначе защита периметра читается как простой сервиса.
#
# ПОЧЕМУ 000 СЧИТАЕТСЯ ОТДЕЛЬНО. curl отдаёт `000`, когда HTTP-ответа не было
# вообще (TCP/TLS не встал, таймаут). Это может быть и простой (Caddy
# пересоздан), и отбой периметра — сваливать его в одну кучу с 502/503 нельзя,
# иначе своя же защита попадёт в числа простоя.
#
# Запуск:
# scripts/probe-deploy-window.sh [URL] [ИНТЕРВАЛ_С] [ДЛИТЕЛЬНОСТЬ_С] [ФАЙЛ]
# scripts/probe-deploy-window.sh --summary <ФАЙЛ> # пересчитать сводку
# scripts/probe-deploy-window.sh --selftest # проверка счётчика серий
#
# Пример (окно деплоя, 10 минут с шагом 200 мс):
# ssh poincare 'nohup /opt/gendesign/scripts/probe-deploy-window.sh \
# https://meraocenka.ru/ 0.2 600 /tmp/probe-mera.tsv >/dev/null 2>&1 &'
set -eu
# Сводка по TSV (epoch<TAB>ISO-время<TAB>код<TAB>секунды): сколько чего, и главное — самая
# длинная серия подряд идущих не-200 в СЕКУНДАХ (по меткам времени самих
# образцов, а не «число образцов × интервал»: curl с таймаутом растягивает шаг,
# и умножение занизило бы реальную длину окна).
summarize() {
awk -F'\t' '
# Серия закрывается первым 200. Длина в секундах считается ТОЛЬКО в END:
# средний шаг известен лишь после прохода, а внутри цикла он ещё 0 — из-за
# этого первая версия занижала окно ровно на один шаг (поймал --selftest).
{ total++; code[$3]++
if ($3 == "200") {
if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at }
streak = 0
} else {
if (!streak) { first_bad_ts = $1; first_bad_at = $2 }
streak++; last_bad_ts = $1
}
if (prev_ts && $1 - prev_ts < 60) { gaps += $1 - prev_ts; gapn++ }
prev_ts = $1
}
END {
step = (gapn ? gaps / gapn : 0)
if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at }
printf "образцов: %d, шаг ~%.2f с\n", total, step
for (c in code) printf " %s: %d\n", c, code[c]
printf "максимальная серия не-200: %d образцов ≈ %.1f с (начало %s)\n", \
max_n, (max_n ? max_last - max_first + step : 0), (max_at ? max_at : "-")
}
' "$1"
}
if [ "${1:-}" = "--summary" ]; then
summarize "$2"
exit 0
fi
if [ "${1:-}" = "--selftest" ]; then
tmp=$(mktemp)
# 10 образцов с шагом 1 с: три подряд не-200 (2-я…4-я секунды) → серия ≈ 3 с.
printf '1000\tT0\t200\t0.01\n1001\tT1\t502\t0.01\n1002\tT2\t000\t5.00\n1003\tT3\t503\t0.01\n1004\tT4\t200\t0.01\n1005\tT5\t200\t0.01\n1006\tT6\t502\t0.01\n1007\tT7\t200\t0.01\n' >"$tmp"
out=$(summarize "$tmp")
rm -f "$tmp"
echo "$out"
echo "$out" | grep -q "максимальная серия не-200: 3 образцов ≈ 3.0 с" || {
echo "SELFTEST FAILED: серия посчитана неверно" >&2
exit 1
}
echo "$out" | grep -q " 000: 1" || { echo "SELFTEST FAILED: 000 не выделен отдельно" >&2; exit 1; }
echo "SELFTEST OK"
exit 0
fi
URL=${1:-https://meraocenka.ru/}
INTERVAL=${2:-1}
DURATION=${3:-900}
OUT=${4:-/tmp/probe-deploy-window.tsv}
: >"$OUT"
echo "проба: $URL каждые ${INTERVAL}с в течение ${DURATION}с$OUT"
end=$(( $(date +%s) + DURATION ))
while [ "$(date +%s)" -lt "$end" ]; do
# --max-time 5: зависший запрос не должен растягивать шаг пробы на минуты.
# -o /dev/null: тело не нужно, важны код и время.
line=$(curl -sS -o /dev/null --max-time 5 -w '%{http_code}\t%{time_total}' "$URL" 2>/dev/null || echo "000 0")
printf '%s\t%s\t%s\n' "$(date +%s.%N)" "$(date -u +%H:%M:%SZ)" "$line" >>"$OUT"
sleep "$INTERVAL"
done
echo "── сводка ─────────────────────────────────────────"
summarize "$OUT"

View file

@ -38,12 +38,137 @@ BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}"
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
fail=0
noresp=0
# --- ТЕМП ЗАПРОСОВ И ПОВТОРЫ (11.09.2026) --------------------------------
#
# ЧТО СЛУЧИЛОСЬ. Прогон на голове main (a659b187) покраснел на двух последних
# проверках списка с кодом `000`. `000` у curl — это НЕ «пришёл неверный код»,
# а «ответа не было вовсе» (таймаут/обрыв). Периметр при этом был цел: те же
# пути, запрошенные вручную, отдавали 503 и 405 от приложения.
#
# ЗАМЕР (внешний IP, https://gendsgn.ru/trade-in/api/v1/me):
# серия без пауз — 5 обрывов из 12
# серия с паузой 0.5 c — 6 обрывов из 12
# серия с паузой 2 c — 0 обрывов из 8
# с самого хоста прода — 0 обрывов из 10 (все 401)
# То есть приложение отвечает, а частую серию запросов с одного внешнего
# адреса отбивает ВХОД (на хосте активен fail2ban). Смоук шлёт ~43 запроса
# подряд без пауз и попадает ровно под этот эффект — и падают именно
# ПОСЛЕДНИЕ проверки, потому что к концу серии счётчик уже набран.
#
# ОТСЮДА ДВА МЕХАНИЗМА, И НИ ОДИН НЕ ТРОГАЕТ САМИ ОЖИДАНИЯ:
# 1. Повтор ТОЛЬКО там, где ответа не было вовсе. Ответ с «не тем» кодом —
# это результат проверки, он не повторяется никогда: иначе ретрай
# маскировал бы настоящий регресс периметра, ради которого всё написано.
# 2. Пауза между проверками, чтобы серия не выглядела флудом.
#
# ПОЧЕМУ ПАУЗА 2 c, А НЕ МЕНЬШЕ. Это наименьшая величина, у которой есть
# замер: 0.5 c измеренно НЕ помогает (6 обрывов из 12 — не лучше, чем без
# пауз), 2 c даёт ноль обрывов, промежуточные значения никто не мерил, и
# взять их означало бы выдумать число. Цена ИЗМЕРЕНА, а не оценена: прогон
# целиком занимал 89 c и стал занимать 160 c (оба замера 11.09 с локальной
# машины, 43 проверки, все зелёные). Под это в воркфлоу периметра поднят
# timeout-minutes: неотвечающая проверка сетевого класса стоит 3×15 c таймаута
# + 2 c и 4 c пауз ретрая + 2 c паузы между проверками = 53 c, а худший случай
# (прод не отвечает вовсе — мертвы все 43) = 43 × 53 ≈ 2279 c ≈ 38 мин.
# Ни 5, ни 10 минут на него не хватало: job убивали ДО печати FAIL-строк и
# итога, то есть ровно там, где лог нужнее всего.
# Обе величины переопределяются из окружения — для отладки локально
# (`SMOKE_PAUSE=0 bash scripts/smoke-mera-perimeter.sh` даёт прежний темп).
SMOKE_PAUSE="${SMOKE_PAUSE:-2}"
SMOKE_ATTEMPTS="${SMOKE_ATTEMPTS:-3}"
# curl_try: запрос с повтором, если ответа не пришло ВООБЩЕ, и с паузой после.
#
# Признак «ответа не было» берём у самого curl — код возврата из СЕТЕВОГО
# класса (перечислен в константе NETWORK_RC ниже). Он строго эквивалентен
# `%{http_code}` = 000, но доступен ВСЕМ проверкам, включая те, которые
# http_code вообще не запрашивают: до этой правки обрыв в
# check_redirect_location читался как «Location не тот», а обрыв при загрузке
# лэндинга — как «сам лэндинг сломан». Один сторож в общей обёртке чинит все
# места сразу, а не только те две проверки, что покраснели.
#
# СЕТЕВОЙ КЛАСС — И ТОЛЬКО ОН. Коды живут в константе, а не в тексте
# комментария, чтобы описание и поведение не разъехались:
# 6 — DNS не разрешился
# 7 — соединение отвергнуто (вход лежит; REJECT у fail2ban выглядит так же)
# 28 — таймаут
# 35 — обрыв на TLS-хендшейке
# 52 — сервер закрыл соединение, не ответив
# 56 — обрыв при приёме ответа
# Общее у них ровно одно: ответа не получено, и повтор имеет шанс помочь —
# тот самый эффект входа, ради которого повтор и заведён.
#
# ОСТАЛЬНЫЕ КОДЫ НЕ ПОВТОРЯЕМ И НЕ ЗОВЁМ «ответа нет». Протухший, чужой или
# самоподписанный сертификат даёт rc=60 (замер 12.09: expired.badssl.com,
# self-signed.badssl.com, wrong.host.badssl.com — все три). Это ИЗМЕРЕННЫЙ
# отказ периметра, а не потерянный запрос: см. проверку 5b в шапке — без
# site-блока Caddy не выпускает сертификат, и клиент видит обрыв TLS вместо
# редиректа, ради этого проверка и написана. Отказ детерминирован: три попытки
# дадут тот же rc, потратив 6 c пауз, а результат уехал бы в колонку «не
# измеряли» — то есть регресс спрятался бы ровно там, где его надо показать.
NETWORK_RC=" 6 7 28 35 52 56 "
# Ответ, пришедший с «неправильным» кодом, для curl — успех (rc=0), повтора не
# будет; проверка отработает ровно так же, как до правки.
curl_try() {
local attempt=1 rc out
while :; do
out=$(curl "$@" 2>/dev/null)
rc=$?
{ [ "$rc" -eq 0 ] || [ "$attempt" -ge "$SMOKE_ATTEMPTS" ]; } && break
# Код вне сетевого класса — повторять нечего, отдаём rc наверх (см. NETWORK_RC).
case "$NETWORK_RC" in *" $rc "*) ;; *) break ;; esac
# Пауза растёт: 2 c, затем 4 c — ниже 2 c смысла нет (см. замер выше).
echo " RETRY: ответа нет (curl rc=$rc), попытка $((attempt + 1)) из $SMOKE_ATTEMPTS через $((attempt * 2)) c: $*" >&2
sleep "$((attempt * 2))"
attempt=$((attempt + 1))
done
[ "$SMOKE_PAUSE" = "0" ] || sleep "$SMOKE_PAUSE"
printf '%s' "$out"
return "$rc"
}
# curl_failed: красная строка, когда ответа не удалось получить у самого curl.
#
# За одним «rc != 0» прячутся ДВА разных диагноза, и путать их нельзя:
# - сетевой класс → «ответа нет»: цел ли периметр, мы не знаем, проверка НЕ
# измерилась (плюс счётчик noresp и отдельная строка в итоге);
# - всё остальное, прежде всего cert-класс (rc=60) → обычный FAIL: отказ
# ИЗМЕРЕН, это регресс периметра, искать надо конфиг, а не флап входа.
#
# rc печатается в ОБЕИХ ветках: строка RETRY при SMOKE_ATTEMPTS=1 не выводится
# вовсе, и без rc читатель красного лога не отличит «домена нет» (6) от
# «сертификат протух» (60) — а это диагнозы из разных отделов.
curl_failed() {
local desc="$1" url="$2" rc="$3" reason
fail=1
case "$NETWORK_RC" in
*" $rc "*)
echo "FAIL (ответа нет): $desc ($url — вход не отдал ответ после $SMOKE_ATTEMPTS попыток, curl rc=$rc;" \
"это НЕ измеренный код ответа, периметр этой проверкой НЕ проверен — повторите URL вручную)"
noresp=$((noresp + 1))
return
;;
esac
case "$rc" in
60|51|83) reason="TLS-сертификат отвергнут" ;;
58|77) reason="проблема с клиентским сертификатом/CA" ;;
*) reason="curl не выполнил запрос" ;;
esac
echo "FAIL: $desc ($url -> $reason (curl rc=$rc); ответ измерен как отказ, это не потерянный" \
"запрос — повтора не было, код вне сетевого класса «ответа нет»)"
}
check() {
local desc="$1" url="$2" expected="$3"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
local code rc
code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
elif [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
@ -51,13 +176,47 @@ check() {
fi
}
# check_any: PASS, если код ответа — ЛЮБОЙ из перечисленных.
#
# Нужен там, где проверяемое свойство — «этого адреса наружу нет», а каким
# именно отказом это выражено, зависит от того, какой рубеж ответил первым.
# На gendsgn.ru таких рубежа два: пилотный basic_auth (401) стоит выше
# allowlist'а site-блока (404), и какой сработает — вопрос порядка директив, а
# не безопасности. Фиксировать один конкретный код значило бы ронять смоук при
# снятии пилотного гейта, то есть при изменении, которое к предмету проверки
# отношения не имеет.
check_any() {
local desc="$1" url="$2"
shift 2
local code rc expected="$*"
code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
for want in "$@"; do
if [ "$code" = "$want" ]; then
echo "PASS: $desc ($url -> $code)"
return
fi
done
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected one of: $expected)"
fail=1
}
check_post() {
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
local out code head_and_body
local out code head_and_body rc
# -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ
# приложения от заглушки Caddy (см. $reject у вызова payments/notify).
out=$(curl -s -i -w '\n%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
out=$(curl_try -s -i -w '\n%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
code=${out##*$'\n'}
head_and_body=${out%$'\n'*}
if [ "$code" != "$expected" ]; then
@ -71,6 +230,72 @@ check_post() {
fi
}
# check_redirect_location: сверяет буквальный заголовок Location редиректа,
# а не только код ответа — нужен там, где регресс не меняет код (301
# остаётся 301), а меняет ТОЛЬКО цель (баг 10.09.2026 у @meraShortSlash в
# apps.caddy: query терялась молча, код ответа был зелёным всю дорогу).
# curl без -L (редирект не проходим), -D - дампит заголовки в stdout.
#
# ФОРМА LOCATION НЕ ФИКСИРУЕТСЯ. Caddy отдаёт цель редиректа так, как её
# собрала директива: для `redir * {uri}` это относительный путь
# (`/articles?utm_source=vc`), а для веток с явным хостом — абсолютный URL
# (`https://meraocenka.ru/articles?utm_source=vc`). Обе формы валидны по
# RFC 7231, браузер разрешает относительную сам, и переписывание одной ветки
# конфига на другую — не регресс, ради которого стоит ронять смоук. Поэтому
# сверяется ХВОСТ: путь с query, с какого бы префикса Location ни начинался.
# Ровно это и есть предмет проверки — что query дожила до цели.
check_redirect_location() {
local desc="$1" url="$2" expected_suffix="$3"
local location headers rc
# Заголовки сначала забираем целиком, и только потом разбираем: при обрыве
# соединения grep по пустому выводу дал бы «Location: <none>» — красную
# строку про подмену цели редиректа там, где ответа не было вовсе.
headers=$(curl_try -s -o /dev/null -D - --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
location=$(printf '%s' "$headers" \
| grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
case "$location" in
"$expected_suffix"|*"$expected_suffix")
echo "PASS: $desc ($url -> Location: $location)"
;;
*)
echo "FAIL: $desc ($url -> got Location '${location:-<none>}', expected ending with '$expected_suffix')"
fail=1
;;
esac
}
# check_content_type: 200 И заявленный Content-Type начинается с ожидаемого.
#
# Заведён под og:image. Для картинки превью «200» само по себе ничего не
# доказывает: перепутанный rewrite приводит к 200 с HTML-страницей фронта, и
# обходчик мессенджера её молча отбросит — ссылка развернётся без картинки, а
# смоук останется зелёным. Проверять надо ровно то, ради чего адрес открыт.
check_content_type() {
local desc="$1" url="$2" expected_prefix="$3"
local out code ctype rc
out=$(curl_try -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
code=${out##*$'
'}
ctype=$(printf '%s' "$out" | grep -i '^content-type:' | tr -d '
' | sed 's/^[Cc]ontent-[Tt]ype: *//' | head -1)
if [ "$code" = "200" ] && [ "${ctype#"$expected_prefix"}" != "$ctype" ]; then
echo "PASS: $desc ($url -> $code, $ctype)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}' / '${ctype:-<no content-type>}', expected 200 + $expected_prefix)"
fail=1
fi
}
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
@ -102,6 +327,22 @@ check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
# 1d3. robots.txt / sitemap.xml — первое, что запрашивает поисковый краулер
# при индексации. robots.txt отдаётся ОТДЕЛЬНЫМ handle (Next-конвенция:
# файл в корне app/, а не под mera-public/), sitemap.xml — той же строкой
# что и остальные @meraPages (см. комментарии в apps.caddy). Отвалится
# любой из handle — сайт не проиндексируется вовсе.
check "meraocenka.ru/robots.txt — public 200" "$BASE_MERA/robots.txt" 200
check "meraocenka.ru/sitemap.xml — public 200" "$BASE_MERA/sitemap.xml" 200
# 1d4. og:image — картинка превью ссылок. Её просят обходчики мессенджеров и
# соцсетей, анонимно и без Referer; отвалится handle — ссылки на сайт
# начнут разворачиваться без картинки, и заметить это по логам
# приложения нельзя (запрос туда просто не доходит). Content-Type
# проверяется вместе с кодом: 200 с HTML вместо PNG выглядел бы так же.
check_content_type "meraocenka.ru/og-mera.png — public 200 + image/png" "$BASE_MERA/og-mera.png" "image/png"
check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BASE_MERA/logo-mera.png" "image/png"
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
@ -110,6 +351,16 @@ check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
# 1f. Регресс-тест на баг 10.09.2026 (замер на живом проде): редирект со
# слэшем терял query-строку — статьи публикуются с UTM-метками, а
# мессенджеры и автолинкификаторы дописывают слэш к скопированной ссылке,
# то есть именно этот трафик терял атрибуцию молча (код ответа
# оставался 301, поэтому предыдущая версия смоука проблему не ловила).
# Проверяем сам факт 301 (уже покрыт проверками check выше по коду) И
# буквальный Location — только вторая половина ловит регресс.
check_redirect_location "meraocenka.ru/articles/?utm_source=vc — query сохраняется на редиректе" \
"$BASE_MERA/articles/?utm_source=vc" "/articles?utm_source=vc"
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
@ -138,8 +389,13 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти
# (замер на проде 02.09.2026: 9 байт).
check_caddy_404() {
local desc="$1" url="$2"
local out code size
out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null)
local out code size rc
out=$(curl_try -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
code=${out%% *}
size=${out##* }
if [ "$code" = "404" ] && [ "$size" = "0" ]; then
@ -160,15 +416,23 @@ check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
# успешной, а публичный сайт молча остался бы без JS.
layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
if [ -z "$layout_chunk" ]; then
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
# чём — поэтому это FAIL, а не «пропустим».
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
fail=1
layout_html=$(curl_try -s --max-time 15 "$BASE_MERA/")
layout_rc=$?
if [ "$layout_rc" -ne 0 ]; then
# Обрыв на загрузке лэндинга раньше попадал в ветку «не нашёл чанк» и читался
# как «сам лэндинг сломан» — диагноз, которого никто не измерял.
curl_failed "HTML лэндинга (ищем в нём layout-чанк)" "$BASE_MERA/" "$layout_rc"
else
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
layout_chunk=$(printf '%s' "$layout_html" \
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
if [ -z "$layout_chunk" ]; then
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
# чём — поэтому это FAIL, а не «пропустим».
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
fail=1
else
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
fi
fi
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
@ -226,6 +490,26 @@ check "trade-in /api/v1/admin/proxies — 404 anonymous (существующа
check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \
"$BASE_MAIN/trade-in/api/v1/admin/users" 404
# 3b. sitemap.xml публичного лэндинга МЕРЫ не должен просачиваться через
# закрытый B2B-контур gendsgn.ru — корень gendsgn.ru принадлежит Site
# Finder, у него нет своего route на /sitemap.xml под МЕРУ, allowlist-by-
# default этого site-блока (как и у meraocenka.ru) отдаёт 404 на всё
# незаявленное.
#
# ДВА ДОПУСТИМЫХ КОДА, А НЕ ОДИН. Первая редакция этой проверки ждала 404 и
# упала на первом же прогоне против прода: корень gendsgn.ru закрыт
# пилотным basic_auth, и гейт отвечает 401 РАНЬШЕ, чем запрос доходит до
# allowlist'а. Оба ответа означают ровно проверяемое — карты сайта МЕРЫ на
# B2B-домене нет; какой именно рубеж сработал первым, к предмету проверки
# отношения не имеет, а вот снятие пилотного гейта (оно запланировано)
# переключит 401 на 404 и уронило бы жёсткое ожидание на пустом месте.
# Красная строка здесь — только 200: он означал бы, что sitemap реально
# отдаётся из закрытого контура.
#
# Если у Site Finder когда-нибудь появится СВОЙ /sitemap.xml, ожидание
# придётся пересмотреть на «200 с другим содержимым».
check_any "gendsgn.ru/sitemap.xml — must NOT serve MERA sitemap (закрытый контур)" "$BASE_MAIN/sitemap.xml" 401 404
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
# на уровне Caddy до бэкенда).
@ -289,6 +573,14 @@ check "trade-in payments/checkout — 401 anonymous (не публичный п
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
echo "========================================"
if [ "$noresp" -gt 0 ]; then
# Отдельная строка в итоге, а не только у самой проверки: читатель красного
# лога должен сразу видеть, что часть проверок НЕ ИЗМЕРИЛАСЬ, и не искать
# регресс периметра там, где ответа просто не было.
echo "БЕЗ ОТВЕТА: $noresp проверок не получили ответа даже после $SMOKE_ATTEMPTS попыток."
echo " Это не измеренный код ответа. Повторите эти URL вручную (и учтите, что"
echo " вход отбивает частые серии запросов с одного адреса — см. шапку скрипта)."
fi
if [ "$fail" -eq 0 ]; then
echo "ALL CHECKS PASSED"
else

View file

@ -79,7 +79,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.geocode import SuggestResponse, suggest_addresses
from app.api.v1.geocode import SuggestResponse, effective_region_code, suggest_addresses
from app.api.v1.trade_in import coverage_probe, estimate
from app.core.config import settings
from app.core.db import get_db
@ -222,6 +222,9 @@ class PublicSuggestInput(BaseModel):
q: str = Field(min_length=2, max_length=200)
limit: int = Field(default=8, ge=1, le=10)
city_hint: str | None = Field(default=None, max_length=100)
# #3051: явный регион с фронта (если он его когда-нибудь пришлёт) —
# приоритетнее вывода из city_hint, см. effective_region_code.
region_code: int | None = Field(default=None)
def _fold(text: str) -> str:
@ -326,6 +329,7 @@ async def public_suggest(
limit=payload.limit,
db=db,
city_hint=payload.city_hint,
region_code=effective_region_code(payload.region_code, payload.city_hint),
)
finally:
_suggest_slots.release()
@ -479,9 +483,15 @@ class ShowcaseStats(BaseModel):
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
посетитель не может отличить выборку из работы оценщика от её лучшего
хвоста. `eligible` минус `written` сколько годных строк не поместилось
в витрину; `rejection_rule` по какому правилу отсеяно остальное,
записанное ТЕМ прогоном, который эти строки посчитал.
хвоста. `eligible` сколько строк прогон СОБРАЛ (данных хватило),
`written` сколько из них показано; `rejection_rule` по какому правилу
отобраны показанные, записанное ТЕМ прогоном, который их посчитал.
`eligible` минус `written` НЕ «столько не поместилось»: с 2026-09-12
витрина показывает полосу расхождения 5 %..+20 %, и в разницу входят
строки, отсеянные полосой. Что это именно отбор, а не вся сверка, говорит
`rejection_rule` поэтому счётчики и правило показываются вместе, одной
подписью, а не порознь.
"""
considered: int
@ -550,7 +560,7 @@ def public_showcase(
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
Вместе со строками едет `stats` сколько сделок рассмотрено, сколько
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
строк прогон собрал и по какому правилу из них отобраны показанные. Числа
считает пересчёт; без них витрина не имеет права подписаться честно.
"""
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()

View file

@ -73,6 +73,7 @@ from app.core.password import (
verify_slots_saturated,
)
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import LOGINS
from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.identity_store import AccessState, get_identity_db
from app.services.user_events import schedule_event
@ -324,6 +325,7 @@ async def _reject_invalid_credentials(
fails = _USERNAME_FAIL_LIMITER.record(username)
delay_s = _throttle_delay_s(fails)
LOGINS.labels(result="failed").inc()
schedule_event(
event_type="login_failed",
username=username,
@ -452,6 +454,7 @@ async def login(
path="/",
)
LOGINS.labels(result="success").inc()
schedule_event(
event_type="login_success",
username=user["username"],

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
@ -11,15 +10,34 @@ from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.core.db import get_db, run_db_thread
from app.observability.metrics import ADDRESS_SUGGESTIONS
from app.services.estimator import _lookup_house_facts
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
from app.services.regions import DEFAULT_REGION_CODE, region_by_city
logger = logging.getLogger(__name__)
router = APIRouter()
def effective_region_code(region_code: int | None, city_hint: str | None) -> int:
"""Регион для геокодера (#3051): явный `region_code` > вывод из `city_hint` > 66.
До этого хелпера `/suggest` всегда уходил в геокодер с регионом 66
по умолчанию московский `city_hint` («Москва») молча получал
свердловский bbox-констрейнт и терял подсказки. Реестр `app.services.regions`
уже знает, каким городам какой регион соответствует (REGIONS[77].cities
содержит «москва») используем его вместо повторного захардкоженного списка.
"""
if region_code is not None:
return region_code
region = region_by_city(city_hint)
if region is not None:
return region.code
return DEFAULT_REGION_CODE
@router.get("/lookup", response_model=GeocodeResult)
async def lookup(
address: Annotated[str, Query(min_length=3, max_length=500)],
@ -82,16 +100,17 @@ async def suggest_addresses(
),
] = None,
region_code: Annotated[
int,
int | None,
Query(
description=(
"Регион покрытия (#3051). Дефолт 66 — Свердловская область, прежнее "
"поведение для существующих клиентов. 77 — Москва: без него DaData "
"и Nominatim получают свердловский hard-констрейнт и молча "
"возвращают ПУСТО на московском адресе."
"Регион покрытия (#3051). None (дефолт) — выводится из `city_hint` "
"через реестр регионов, иначе 66 (Свердловская область, прежнее "
"поведение). 77 — Москва: без него DaData и Nominatim получают "
"свердловский hard-констрейнт и молча возвращают ПУСТО на "
"московском адресе."
),
),
] = 66,
] = None,
) -> SuggestResponse:
"""Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область;
ЕКБ основной трафик, остаётся быстрым fast-path).
@ -106,11 +125,15 @@ async def suggest_addresses(
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
/api/v1/geocode/suggest?q=Тверская+6&region_code=77 # Москва
"""
resolved_region_code = effective_region_code(region_code, city_hint)
try:
items = await suggest(q, db=db, limit=limit, city_hint=city_hint, region_code=region_code)
items = await suggest(
q, db=db, limit=limit, city_hint=city_hint, region_code=resolved_region_code
)
except ValueError as exc:
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
raise HTTPException(status_code=422, detail=str(exc)) from exc
ADDRESS_SUGGESTIONS.labels(found="yes" if items else "no").inc()
return SuggestResponse(
items=[
SuggestItem(
@ -254,9 +277,9 @@ async def house_facts(
"""
target_house_id: int | None = None
if fias_id is not None:
target_house_id = await asyncio.to_thread(_resolve_house_id_by_fias, db, fias_id)
target_house_id = await run_db_thread(_resolve_house_id_by_fias, db, fias_id)
facts = await asyncio.to_thread(
facts = await run_db_thread(
_lookup_house_facts,
db,
target_house_id=target_house_id,

View file

@ -50,10 +50,14 @@ from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, ValidationError
from starlette.background import BackgroundTask
from app.core.config import settings
from app.services.tgbot.client import TelegramApiError, TelegramClient
from app.services.tgbot.client import TelegramError
from app.services.tgbot.shared import get_telegram_client
from app.tasks.glitchtip_alert_retry import retry_forward_alert
logger = logging.getLogger(__name__)
@ -183,16 +187,28 @@ def _verify_secret(provided: str) -> None:
raise HTTPException(status_code=401, detail="invalid or missing secret")
@router.post("/ops/glitchtip-webhook")
@router.post("/ops/glitchtip-webhook", response_model=None)
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
) -> dict[str, str]:
) -> dict[str, str] | JSONResponse:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
Отказ синхронной попытки (#3471) отвечает 502 как и раньше (#3456 — честный
сигнал отправителю), но ставит доставку в фон
(``app.tasks.glitchtip_alert_retry.retry_forward_alert`` через
``starlette.background.BackgroundTask`` на самом ответе) GlitchTip вебхуки
не ретраит (#3157), без этого текст алерта терялся бы безвозвратно.
``BackgroundTask`` привязан НАПРЯМУЮ к возвращаемому ``JSONResponse``, а не
к ``BackgroundTasks``-зависимости: FastAPI прикрепляет задачи из
``BackgroundTasks`` только к ответу, который вернул сам хендлер, а `raise
HTTPException` строит ОТДЕЛЬНЫЙ ответ в exception-мидлваре задачи,
поставленные до `raise`, в реальности молча терялись бы вместе с ним (это
воспроизведено тестом, не гипотеза).
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) этот
хендлер сам делает единственную проверку секрета.
@ -213,7 +229,10 @@ async def glitchtip_webhook(
received_at = datetime.now(UTC)
text = _build_message(raw_body, received_at)
client = TelegramClient(settings.telegram_bot_token)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
try:
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
@ -224,8 +243,30 @@ async def glitchtip_webhook(
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
except TelegramError:
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
# (#3471, см. app.tasks.glitchtip_alert_retry).
#
# `raise HTTPException` здесь НЕ подходит: FastAPI прикрепляет
# background-задачи только к ответу, который вернул сам хендлер, а
# исключение строит СВОЙ отдельный JSONResponse в exception-мидлваре —
# задача, поставленная до `raise`, никогда бы не выполнилась. Поэтому
# 502 собран и возвращён вручную, с задачей на этом же объекте ответа.
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
return JSONResponse(
status_code=502,
content={"detail": "failed to forward alert to telegram"},
background=BackgroundTask(
retry_forward_alert,
client,
chat_id=settings.telegram_alerts_chat_id,
text=text,
message_thread_id=settings.telegram_alerts_topic_id or None,
),
)
return {"status": "ok"}

View file

@ -33,6 +33,7 @@ from sqlalchemy.orm import Session
from app.api.v1.trade_in import _assert_estimate_access
from app.core.config import settings
from app.core.db import get_db
from app.observability.metrics import LEADS
logger = logging.getLogger(__name__)
@ -53,12 +54,19 @@ _PHONE_MAX_DIGITS = 15
# только в audit-лог (#2497 TODO, теперь закрыт).
#
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» →
# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию
# mera-public/content.ts: «приказом директора № 2 от 10 сентября 2026 г.» →
# "2026-09-10"), а не дата этого коммита — версия обязана указывать на редакцию
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
_CONSENT_POLICY_VERSION = "2026-08-13"
#
# Редакция № 2 от 10.09.2026: в политику добавлен раздел 9 «Файлы cookie и
# веб-аналитика» (на публичный сайт поставлены Яндекс.Метрика и GA4), прежний
# раздел «Контакты» стал десятым. Согласия, собранные до этой даты, ссылаются
# на редакцию № 1 и остаются с версией "2026-08-13" — в этом и смысл хранить
# версию per-row: снимок согласия должен указывать на тот документ, который
# человек видел, а не на текущий.
_CONSENT_POLICY_VERSION = "2026-09-10"
# Снимок точного текста согласия, который видит пользователь при отправке лида
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
@ -166,6 +174,7 @@ async def create_trade_in_lead(
)
db.commit()
LEADS.inc()
logger.info(
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",

View file

@ -14,7 +14,6 @@ module docstring (честно про то, что не всегда разре
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from uuid import UUID
@ -23,7 +22,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.core.db import get_db, run_db_thread
logger = logging.getLogger(__name__)
@ -67,7 +66,7 @@ async def erase_person_data_endpoint(
from app.services.data_erasure import erase_person_data
counters = await asyncio.to_thread(
counters = await run_db_thread(
erase_person_data,
db,
username=payload.username,

View file

@ -62,18 +62,23 @@ import hashlib
import logging
import re
import secrets
import time
from datetime import UTC, datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import SUPPORT_MESSAGES
from app.services.tgbot import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramApiError, TelegramClient
from app.services.tgbot.client import TelegramError
from app.services.tgbot.shared import get_telegram_client
logger = logging.getLogger(__name__)
@ -122,10 +127,58 @@ _INTERACTIVE_SEND_TIMEOUT_S = 5.0
_INTERACTIVE_SEND_MAX_RETRIES = 3
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
# Счётчик ОТКАЗОВ отправки — отдельный от бюджетов выше (#tgsupport-fail-cooldown).
#
# Зачем вообще второй счётчик. `_send_limiter`/`_anon_ip_limiter` расходуются
# ТОЛЬКО на успехе (review L3, non-destructive peek выше) — это верно для
# «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram
# недоступен, лимита нет ВООБЩЕ. Каждый повтор пользователя при этом стоит до
# `1 + _INTERACTIVE_SEND_MAX_RETRIES` = 4 попыток к api.telegram.org и не
# расходует ни один бюджет. Двух-трёх вкладок с авто-ретраем хватает, чтобы
# выесть лимиты группы ровно в тот момент, когда канал и так еле жив.
#
# Отсюда — дешёвый gate ПЕРЕД походом в Telegram, на своих ключах (тех же, что
# у основных лимитеров: username / anon-thread-key и client IP).
#
# N=5. Отказ одной отправки — не событие: по замеру #tgsupport-retry доля отказов
# на попытку 15-38%, но после 4 попыток до пользователя доходит ~0.8-2% отказов.
# Пять подряд на живом канале — вероятность порядка 1e-10, то есть cooldown
# физически не может сработать на «просто не повезло»; он срабатывает только на
# настоящей недоступности. Плюс `reset()` на успехе: считаем именно ПОДРЯД, одна
# успешная отправка стирает историю.
#
# 30с окна (оно же длительность cooldown: блок держится, пока самый старый из
# N отказов не выпадет из окна). Верхняя граница осмысленности — реальная
# недоступность Telegram по тому же замеру длится минутами, так что 30с заведомо
# короче и пользователя после восстановления канала не наказывают. Нижняя —
# один отказавший интерактивный запрос сам по себе занимает до 23с
# (4 попытки × 5с + 3 паузы × 1с), cooldown короче этого просто не имел бы смысла.
_SEND_FAILURE_LIMIT = 5
_SEND_FAILURE_WINDOW_S = 30.0
_send_failure_limiter = SlidingWindowLimiter(
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
)
# Отдельный экземпляр для IP-ключей — ровно как `_anon_ip_limiter` отдельный от
# `_send_limiter`: ключи разных пространств (IP vs thread-key) в одном словаре
# смешивать нельзя.
_anon_ip_failure_limiter = SlidingWindowLimiter(
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
)
_SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попробуйте через полминуты."
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
_LIST_MESSAGES_LIMIT = 200
# Идемпотентность отправки (#3471 retry-storm) — см. `_resolve_idempotency_key`.
_IDEMPOTENCY_HEADER = "idempotency-key"
# Форма клиентского ключа — как у anon-токена (`_ANON_TOKEN_RE`): произвольная
# opaque-строка клиента, без пробелов/спецсимволов, которые попали бы в SQL-параметр
# как есть. Не матчится — считаем заголовок отсутствующим и уходим на fallback,
# а не пытаемся его "починить" (тот же принцип, что у `_read_anon_token`).
_CLIENT_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{8,128}\Z")
def _require_username(request: Request) -> str:
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
@ -165,6 +218,18 @@ class SupportMessageOut(BaseModel):
text_body: str
operator_tg_id: int | None = None
created_at: str
# Дошло ли сообщение до БД. `True` по умолчанию — все существующие пути
# (`GET /support/messages`, успешный POST) строят модель из storage-строки и
# ничего про флаг не знают, контракт для них не меняется.
#
# `False` ставит ТОЛЬКО `_unpersisted_message_out`: доставлено оператору, но
# не записано. Флаг нужен потому, что без него деградация неотличима от
# тишины: фронт выбрасывает тело POST и рендерит транскрипт исключительно из
# `GET /support/messages` (`useSupportChat.ts`), где этого сообщения нет —
# поле ввода очищается, сообщение не появляется, ошибки нет. Пользователь
# решает, что не отправилось, и шлёт снова — ровно тот дубль в топике,
# против которого вся эта ветка и сделана.
persisted: bool = True
@field_validator("created_at", mode="before")
@classmethod
@ -190,8 +255,150 @@ def _format_mirror_text(username: str, message_text: str) -> str:
return f"[С САЙТА] {username}:\n{message_text}"
def _too_many_failures_error(retry_after: float) -> HTTPException:
"""429 вместо похода в Telegram — канал только что отказал N раз подряд."""
return HTTPException(
status_code=429,
detail=_SEND_UNAVAILABLE_DETAIL,
headers={"Retry-After": str(int(retry_after) + 1)},
)
def _unpersisted_message_out(text_body: str) -> SupportMessageOut:
"""Синтетический ответ для случая «в топик доставлено, а в БД не записано».
Почему ответ вообще УСПЕШНЫЙ. Сообщение оператору реально доставлено
отдать на это ошибку значит соврать: пользователь повторит, и в топике
окажется дубль (плюс второе предупреждение оператору). Успех здесь честнее
отказа, но он неполный, и об этом клиенту надо сказать явно.
Что сообщает `persisted=False`: «доставлено, но ответить тебе через чат не
смогут; повторять не надо». Именно флаг контракт для клиента, а НЕ `id=0`:
по идентификатору клиент отличить деградацию не обязан и не будет.
`id` при этом всё равно нужен модели, и 0 сознательный сентинел, а не
выдумка: реальные id в `web_support_messages` начинаются с 1 (serial),
поэтому 0 ни с чем не столкнётся, а `GET /support/messages` принимает
`since>=0`. Даже если фронт когда-нибудь начнёт курсорить по возвращённому
id (сейчас он перечитывает тред целиком с `since=0`, `useSupportChat.ts`),
нулевой курсор не перепрыгнет ни одного реального сообщения: занижение
курсора безопасно, завышение нет.
"""
return SupportMessageOut(
id=0,
direction="in",
text_body=text_body,
operator_tg_id=None,
created_at=datetime.now(UTC),
persisted=False,
)
async def _warn_operator_message_not_recorded(*, label: str, topic_message_id: int | None) -> None:
"""Реплаем к только что доставленному зеркалу предупреждает оператора, что
ответ на ЭТО сообщение не смаршрутизируется: треда в БД нет, на реплай к
осиротевшему зеркалу `bridge._handle_group_reply` напишет только WARNING, а
клиент не увидит ничего. Без предупреждения оператор отвечает в пустоту и
считает, что помог.
Текст обращения сюда НЕ дублируется (ПДн) оператор видит его в сообщении,
к которому это реплай.
Формулировка учитывает, что клиенту ответили успехом (`persisted=False`,
«принято, повторять не надо»): рассчитывать на «клиент напишет снова»
оператору нельзя, поднимать тред придётся иначе.
Любой отказ гасится логом: основное сообщение УЖЕ доставлено, превращать
неудачу служебного уведомления в 500 поверх успеха нельзя.
"""
text = (
f"ВНИМАНИЕ · {label}: сообщение доставлено в топик, но НЕ записано в базу "
"(сбой БД). Треда у этого обращения нет — ответить клиенту через бота "
"НЕЛЬЗЯ: реплай на это сообщение никуда не уйдёт. Повтора тоже не ждите, "
"клиенту показано, что сообщение принято и отправлять его снова не нужно. "
"Если в обращении есть контакт — свяжитесь напрямую; иначе передайте "
"дежурному и сообщите об отказе БД."
)
try:
client = get_telegram_client()
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=text,
message_thread_id=settings.telegram_support_topic_id or None,
# reply_to может отсутствовать (sendMessage не вернул message_id) —
# тогда уведомление уходит отдельным сообщением в топик: хуже, чем
# реплай, но несравнимо лучше тишины.
reply_to_message_id=topic_message_id,
# Тот же узкий интерактивный бюджет (review H1): клиент ждёт ответа
# ручки, а не доставки служебного уведомления.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except Exception:
# Шире `TelegramError` намеренно: это best-effort хвост уже успешного
# запроса, любой отказ здесь обязан остаться в логе, а не у клиента.
logger.exception(
"web support: не удалось предупредить оператора о несохранённом сообщении (%s)",
label,
)
def _rollback_quietly(db: Session) -> None:
"""Откат после `SQLAlchemyError`. Сам rollback на мёртвом соединении тоже
может бросить, а мы уже решили отдать клиенту успех `get_db` в любом
случае закроет сессию в `finally`."""
try:
db.rollback()
except SQLAlchemyError:
logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True)
def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str) -> str:
"""Ключ идемпотентности inbound-отправки (#3471, миграция 301).
Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key`
ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
после таймаута) независимо от того, что именно менялось в UI между
попытками (см. `useSendSupportMessage` во фронтенде реальный трафик
обязан идти этим путём). Fallback без заголовка детерминированный
отпечаток sha256(identity, текст, минутное окно) существует ТОЛЬКО для
клиентов, которые заголовок не прислали (п.5 требования старое поведение
не должно сломаться), и у него два честных изъяна, оба снимаются самим
фактом использования заголовка:
1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
СХЛОПНУТСЯ в одно второе будет молча проглочено, клиент получит id
первого, оператор не увидит второе сообщение вообще;
2. окно не скользящие 60 секунд, а округление `time.time() // 60` вниз:
фактический срок дедупликации случаен от 0 до 60с в зависимости от
момента внутри минуты, и часть настоящих повторов (ретрай ровно на
границе окна) эту защиту не получит.
Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
(см. `useSendSupportMessage`).
Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ
он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
попадает ТОЛЬКО как вход в sha256, в открытом виде не сохраняется и не
возвращается жёсткое правило проекта "текст не в логах" (ПДн) при этом
не нарушается, даже если бы этот ключ где-то залогировали.
Префиксы `client:`/`auto:` разводят два namespace'а ключей (защита от
случайного совпадения клиентского токена с fallback-хэшем) дёшево и не
требует отдельной колонки.
"""
header = (request.headers.get(_IDEMPOTENCY_HEADER) or "").strip()
if header and _CLIENT_IDEMPOTENCY_KEY_RE.match(header):
return f"client:{header}"
window = int(time.time() // 60)
fingerprint = f"{identity_key}|{text}|{window}"
return "auto:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
@router.post("/support/messages", response_model=SupportMessageOut)
async def send_support_message(
request: Request,
payload: SupportMessageInput,
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
@ -219,7 +426,47 @@ async def send_support_message(
headers={"Retry-After": str(int(retry_after) + 1)},
)
client = TelegramClient(settings.telegram_bot_token)
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`): канал только что отказал
# N раз подряд — не тратим на этот запрос ещё четыре попытки к Telegram.
cooldown = _send_failure_limiter.retry_after(username)
if cooldown is not None:
raise _too_many_failures_error(cooldown)
# Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
# переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
# успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
# плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
# `send_message` не удался, ключ нигде не записан, и повтор клиента после
# неудачи это законная первая попытка. Реально закрывается другой, тоже
# частый случай: доставка УЖЕ состоялась (Telegram принял, строка
# закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
# клиентский таймаут) или пользователь кликнул "отправить" второй раз по
# той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
# записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
# Тред может ещё не существовать (это первая попытка этого ключа) — тогда
# сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
# здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
# если бы сообщение под этим ключом уже было записано, тред уже был бы.
idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
existing_thread_id = storage.find_thread_id(db, username)
if existing_thread_id is not None:
existing = storage.find_inbound_by_idempotency_key(
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
)
if existing is not None:
logger.info(
"web support: repeat send (idempotency key already recorded) "
"username=%s thread_id=%d message_id=%s",
username,
existing_thread_id,
existing["id"],
)
return SupportMessageOut(**existing)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
@ -230,16 +477,22 @@ async def send_support_message(
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except TelegramApiError:
except TelegramError:
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
# TelegramApiError и не бывает — см. client.py docstring про redaction).
# Предок, а не `TelegramApiError`: при таймауте до Telegram пользователь
# должен увидеть тот же «сервис недоступен», а не 500 (#3456).
logger.exception(
"web support: не удалось отправить зеркало в топик (username=%s)", username
)
_send_failure_limiter.record(username)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
_send_limiter.record(username)
SUPPORT_MESSAGES.labels(channel="web").inc()
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
_send_failure_limiter.reset(username)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
@ -253,15 +506,35 @@ async def send_support_message(
)
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
thread_id = storage.get_or_create_thread(db, username)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
#
# Оборотная сторона этого порядка: отказ БД здесь означает, что сообщение
# оператору УЖЕ доставлено. Отдавать на это 500 (как было) — худший из
# вариантов: клиент видит ошибку, шлёт повторно, в топике дубль, а на
# осиротевшее зеркало оператор отвечает в пустоту. Поэтому отвечаем успехом
# (доставка правда состоялась) и отдельным сообщением предупреждаем
# оператора, что отвечать на это зеркало бесполезно.
try:
thread_id = storage.get_or_create_thread(db, username)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
idempotency_key=idempotency_key,
)
db.commit()
except SQLAlchemyError:
# Ни текста обращения (ПДн), ни токена в логе — только кто и что сломалось.
logger.exception(
"web support: сообщение доставлено в топик, но не записано в БД (username=%s)",
username,
)
_rollback_quietly(db)
await _warn_operator_message_not_recorded(
label=f"[С САЙТА] {username}", topic_message_id=topic_message_id
)
return _unpersisted_message_out(payload.text)
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
return SupportMessageOut(**row)
@ -408,8 +681,46 @@ async def send_anon_support_message(
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`) — оба ключа, как и у
# бюджетов выше: per-token ловит одну вкладку с авто-ретраем, per-IP — ту же
# петлю после сброса куки.
for cooldown in (
_send_failure_limiter.retry_after(thread_key),
_anon_ip_failure_limiter.retry_after(ip),
):
if cooldown is not None:
raise _too_many_failures_error(cooldown)
display_id = _anon_display_id(token)
client = TelegramClient(settings.telegram_bot_token)
# Идемпотентность (#3471) — см. развёрнутый комментарий в авторизованной
# ветке. Ограничение специфично для анонимной ветки: если предыдущая
# попытка сама провалилась ДО выдачи куки (`is_new_token=True` тогда и
# сейчас), identity_key каждый раз новый (случайный токен) и pre-check
# структурно не может найти прошлую попытку — это тот же класс проблемы,
# что и потеря куки в сети, вне scope этой правки.
idempotency_key = _resolve_idempotency_key(request, identity_key=thread_key, text=payload.text)
existing_thread_id = storage.find_thread_id(db, thread_key)
if existing_thread_id is not None:
existing = storage.find_inbound_by_idempotency_key(
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
)
if existing is not None:
logger.info(
"web support (anon): repeat send (idempotency key already recorded) "
"%s thread_id=%d message_id=%s",
display_id,
existing_thread_id,
existing["id"],
)
if is_new_token:
_set_anon_cookie(response, token)
return SupportMessageOut(**existing)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
@ -419,15 +730,22 @@ async def send_anon_support_message(
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except TelegramApiError:
except TelegramError:
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
# Про предок вместо `TelegramApiError` — см. комментарий в парной ручке.
logger.exception(
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
)
_send_failure_limiter.record(thread_key)
_anon_ip_failure_limiter.record(ip)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
_send_limiter.record(thread_key)
_anon_ip_limiter.record(ip)
SUPPORT_MESSAGES.labels(channel="anon").inc()
# Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы.
_send_failure_limiter.reset(thread_key)
_anon_ip_failure_limiter.reset(ip)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
@ -437,15 +755,37 @@ async def send_anon_support_message(
display_id,
)
thread_id = storage.get_or_create_thread(db, thread_key)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
# Отказ БД после успешной отправки — см. развёрнутый комментарий в парной
# (авторизованной) ручке: сообщение оператору уже доставлено, 500 тут создаёт
# дубли в топике и «осиротевшее» зеркало, на которое оператор отвечает зря.
try:
thread_id = storage.get_or_create_thread(db, thread_key)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
idempotency_key=idempotency_key,
)
db.commit()
except SQLAlchemyError:
# Ни текста обращения (ПДн), ни токена (bearer треда) в логе — только
# несекретный ярлык треда.
logger.exception(
"web support (anon): сообщение доставлено в топик, но не записано в БД (%s)",
display_id,
)
_rollback_quietly(db)
await _warn_operator_message_not_recorded(
label=f"[С САЙТА · БЕЗ ВХОДА] {display_id}", topic_message_id=topic_message_id
)
# Куку ставим ВСЁ РАВНО: тред в БД не создан, но идентичность посетителя
# обязана пережить этот сбой — иначе следующее сообщение (когда база
# поднимется) заведёт ВТОРОЙ тред, и переписка разъедется на два.
if is_new_token:
_set_anon_cookie(response, token)
return _unpersisted_message_out(payload.text)
if is_new_token:
_set_anon_cookie(response, token)

View file

@ -22,6 +22,7 @@ from app.core.anon_session import get_or_create_anon_session_id
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import ESTIMATES, REPORTS_EXPORTED
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
@ -79,8 +80,10 @@ _estimate_limiter = SlidingWindowLimiter(
# внешние тиры; пила одновременных оценок выедает пул и тормозит весь /api/v1/*.
# Образец — public/mera.py::_suggest_slots (4 слота на секундное автодополнение).
#
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений
# из 15 возможных, остаток пула остаётся прочим ручкам. Ожидание слота 5с ≈ две
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений.
# Пул под это заведомо шире: 5+15=20 на процесс, и потолок пула держится не
# меньше СУММЫ объявленных потолков одновременности, включая 8 фоновых догрузок
# (core/db.py + tests/test_3408_pool_ceiling.py, #3408). Ожидание слота 5с ≈ две
# длительности оценки: если за это время слот не освободился, очередь глубока и
# честный ответ — быстрый 429 с Retry-After, а не растущая очередь (очередь под
# нагрузкой — те же занятые соединения плюс таймаут у клиента; mera.py:117-127).
@ -558,6 +561,13 @@ async def estimate(
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
# сериализация ответа ниже дёшевы и слот держать не должны.
_estimate_slots.release()
# #3471: считаем оценку "успешно посчитанной" здесь — до квоты и до 429
# ниже, потому что расчёт (дорогая часть) уже прошёл. insufficient_data —
# тоже успех расчёта: медиана не нашлась не потому что что-то сломалось, а
# потому что аналогов не было, это отдельный, а не ошибочный исход.
ESTIMATES.labels(outcome="insufficient_data" if result.insufficient_data else "ok").inc()
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
@ -1011,6 +1021,7 @@ def estimate_pdf(
brand_obj = _resolve_brand(owner_brand_slug, db)
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
REPORTS_EXPORTED.inc()
logger.info(
"PDF generated estimate_id=%s brand=%s size=%d",
estimate_id,
@ -2185,8 +2196,11 @@ def get_street_deals(
"""ДКП-сделки Росреестра по улице целевого адреса.
Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
Поэтому это per-street view, не per-house. Фильтр по rooms + area
сужает выборку до квартир-аналогов.
Поэтому это per-street view, не per-house. До квартир-аналогов выборку
сужает полоса площади ±area_tolerance; комнатность клиента в фильтр НЕ
входит `deals.rooms` не комнатность, а синтетика из той же площади
(#3256, см. estimator._fetch_dkp_corridor). `rooms` остаётся параметром
ручки: он описывает запрос и попадает в лог, но не в WHERE.
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
в import-rosreestr.sh).
@ -2196,6 +2210,7 @@ def get_street_deals(
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
now = datetime.now(tz=UTC)
@ -2239,6 +2254,17 @@ def get_street_deals(
target_city = _resolve_target_city(address)
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
# #dkp-corridor-scope (2026-09-12): region_code — обязательный фильтр, в отличие
# от city_filter выше (который применяется, только если _resolve_target_city
# узнал город обл.66). extract_street_name после фикса московского порядка
# ("Название улица") стал возвращать имя улицы и для Москвы, а
# _resolve_target_city знает ТОЛЬКО города обл.66 → для Москвы target_city=None,
# city_filter пуст, и без region_code одноимённая улица другого региона
# подмешалась бы в выборку (прод-замер: "Ясная" — 168 сделок в 66 и 80 в 77,
# "Советская" — 1202 и 17). region_code заполнен у всех deals (66→108 623,
# 77→212 937, NULL нет) — фильтр не отрезает ни одной существующей строки.
region_code = region_code_for_address(address)
rows = (
db.execute(
text(
@ -2250,7 +2276,11 @@ def get_street_deals(
AND address ILIKE :street_pattern
AND address ~* :street_regex
{city_filter}
AND rooms = CAST(:rooms AS integer)
AND region_code = CAST(:region_code AS integer)
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
-- в комментарии estimator._fetch_dkp_corridor.
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
AND price_rub > 0
@ -2261,7 +2291,7 @@ def get_street_deals(
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None,
"rooms": rooms,
"region_code": region_code,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -2272,12 +2302,16 @@ def get_street_deals(
)
if not rows:
# #3256: лог называет ТОТ ключ, которым искали. Комнатность клиента в
# выборку не входит (deals.rooms — синтетика из площади), поэтому она
# печатается как контекст запроса, а не как параметр фильтра.
logger.info(
"street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%",
"street-deals: no rows found street=%r area=%.1f±%.0f%% "
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
street_name,
rooms,
area_m2,
area_tolerance * 100,
rooms,
)
return StreetDealsResponse(
street=street_name,
@ -2305,12 +2339,14 @@ def get_street_deals(
top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
logger.info(
"street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f",
"street-deals: street=%r area=%.1f±%.0f%% count=%d median_ppm2=%.0f "
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
street_name,
rooms,
area_m2,
area_tolerance * 100,
count,
median_ppm2,
rooms,
)
return StreetDealsResponse(
@ -2475,8 +2511,12 @@ def get_sales_vs_listings(
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
matching listing на той же улице с такими же rooms / близкой area_m2 /
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
matching listing на той же улице с близкой area_m2 / listing_date в окне
[deal_date - window_days, deal_date + 30d grace]. Комнатность в ключе стоит
ТОЛЬКО на стороне объявлений (`l.rooms = p_rooms`, комнатность клиента): у
сделок Росреестра `rooms` синтетика из площади, предикат по ней снят
миграцией 300 (#3451/#3256). Поэтому `deal_rooms` в паре может не совпадать
с запрошенным `rooms`.
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
чтобы вычислить linkage_rate.
@ -2486,7 +2526,12 @@ def get_sales_vs_listings(
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
"""
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
from app.services.estimator import (
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
return SalesVsListingsResponse(
@ -2516,16 +2561,36 @@ def get_sales_vs_listings(
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
target_city = _resolve_target_city(address)
# #dkp-corridor-scope (2026-09-12): region_code — фильтр ДОПОЛНИТЕЛЬНО к
# target_city выше, нужен по той же причине, что и в /street-deals: для
# Москвы _resolve_target_city (словарь городов ТОЛЬКО обл.66) возвращает
# None → target_city-фильтр внутри TVF (миграция 205) не срабатывает ни
# для deals, ни для listings, и одноимённая улица другого региона
# подмешивается в пары (см. estimator.region_code_for_address).
#
# TVF street_sales_vs_listings() (миграция 205) параметра региона не
# знает — сама TVF не трогается (это отдельная миграция, вне текущего
# фикса), фильтр применён СНАРУЖИ: оборачиваем вызов в JOIN на deals по
# deal_id и фильтруем region_code ТОЛЬКО на стороне сделок. Сторона
# listings внутри TVF остаётся НЕ отфильтрованной по региону — это
# известный узкий компромисс, не побочный эффект: у listings нет
# общего для всех источников поля региона (city заполнен частично, см.
# комментарий 205 выше), а сам JOIN внутри TVF уже требует совпадения
# street_pattern + rooms + area + date proximity, что резко сужает шанс
# чужого региона на listing-стороне. Полный фикс (передать region_code
# внутрь TVF седьмым/восьмым параметром) — отдельная миграция.
region_code = region_code_for_address(address)
rows = (
db.execute(
text(
"""
SELECT
deal_id, deal_date, deal_price_rub, deal_price_per_m2,
deal_area_m2, deal_rooms, deal_floor, deal_address,
listing_id, listing_source, listing_source_url,
listing_date, listing_price_rub, listing_price_per_m2,
listing_area_m2, days_listing_to_deal, discount_pct
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
sv.listing_id, sv.listing_source, sv.listing_source_url,
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct
FROM street_sales_vs_listings(
CAST(:street_pattern AS text),
CAST(:area_m2 AS numeric),
@ -2534,7 +2599,9 @@ def get_sales_vs_listings(
CAST(:area_tolerance AS numeric),
CAST(:period_months AS integer),
CAST(:target_city AS text)
)
) sv
JOIN deals d ON d.id = sv.deal_id
WHERE d.region_code = CAST(:region_code AS integer)
"""
),
{
@ -2545,6 +2612,7 @@ def get_sales_vs_listings(
"area_tolerance": area_tolerance,
"period_months": period_months,
"target_city": target_city,
"region_code": region_code,
},
)
.mappings()

View file

@ -47,6 +47,7 @@ from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
from app.core.db import DB_CONNECT_ARGS
class AuthDatabaseNotConfiguredError(RuntimeError):
@ -101,6 +102,18 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
# потолок на одном движке и мина на втором это не починка, а половина.
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
# --workers) — включая `/health`.
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
# же путь, что и любой другой сбой реестра, а не 500.
connect_args=DB_CONNECT_ARGS,
)
except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до

View file

@ -1287,6 +1287,69 @@ class Settings(BaseSettings):
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
# Общий (не per-тему) лимит частоты отправки в ОДНУ группу — Telegram
# считает ~20 сообщений/минуту на группу суммарно по всем её темам (#3471:
# всплеск GlitchTip-алертов + поток поддержки в ту же группу давали 429 и
# потерю сообщений). `TELEGRAM_SUPPORT_CHAT_ID`/`TELEGRAM_ALERTS_CHAT_ID` на
# проде равны (одна группа, темы разные) — обе половины делят один
# площадочный бюджет.
#
# РАЗДЕЛЕНО ПО РОЛЯМ (review H2, #3471), а не одна общая константа: лимитер
# живёт in-memory В ЭКЗЕМПЛЯРЕ `TelegramClient`, а в эту группу пишут ДВА
# независимых процесса — API под uvicorn (`app/services/tgbot/shared.py`,
# интерактивные ручки + GlitchTip-вебхук) и контейнер бота (`app/tgbot_main.py`,
# long-polling воркер). У них НЕТ общего счётчика (это отдельная задача —
# Redis-based распределённый лимитер), поэтому если каждому дать по 18,
# сумма (2×18=36) УДВОИТ площадочный лимit и 429 вернётся ровно там же.
# Бюджет делится статически так, чтобы СУММА была заметно НИЖЕ ~20: у API
# больше — там же интерактивные ответы клиентам, у бота меньше — там же
# обычно только зеркалирование/уведомления, которые могут подождать дольше
# (см. `TelegramGroupRateLimiter.acquire` про `max_wait=None` для фона).
# 0/отрицательное значение выключает лимитер для соответствующего процесса.
#
# ЧЕСТНО ПРО ОГРАНИЧЕНИЕ ЭТОГО ДИЗАЙНА (review, #3471):
# `telegram_group_rate_limit_api_per_minute` — ОДИН общий бюджет на ВСЕ
# отправки процесса API в эту группу, а туда
# пишут И зеркала веб-чата поддержки (`app/api/v1/support.py`), И
# GlitchTip-алерты (`app/api/v1/glitchtip.py`) — обе ручки идут через один
# и тот же `get_telegram_client()` (см. `app/services/tgbot/shared.py`).
# Приоритета между ними НЕТ: кто первый встал в очередь `TelegramGroupRateLimiter`,
# тот и получил слот. Оба пути передают узкий `timeout` (5с у support, 8с у
# glitchtip) — он же становится потолком ожидания слота (см.
# `TelegramClient._request`, review H1). Значит при всплеске алертов (пачка
# ошибок прода бьёт в вебхук залпом) реально возможен сценарий: бюджет
# 12/мин исчерпан алертами → следующая отправка живого клиента в веб-чате
# ждёт до 5с и получает `TelegramRateLimitedError` → 502 клиенту поддержки.
# То есть при достаточно большом всплеске алертов веб-чат ДЕЙСТВИТЕЛЬНО
# может временно вставать. Разделить бюджет по ИСТОЧНИКУ (не по процессу) —
# отдельная задача: нужен свой `TelegramGroupRateLimiter` на алерты с явно
# малой квотой и/или приоритет для support-трафика; здесь НЕ сделано
# (вне бюджета этой правки).
telegram_group_rate_limit_api_per_minute: int = Field(
default=12, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_API_PER_MINUTE"
)
telegram_group_rate_limit_bot_per_minute: int = Field(
default=6, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_BOT_PER_MINUTE"
)
# ── Ретранслятор Bot API через Beget (#3471) ─────────────────────────────
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` с Selectel — 9
# успешных из 12 (три ConnectTimeout), TCP-443 до адреса Selectel — 5/6, TCP-443
# до адреса Beget — 8/8; за сутки 508 строк `network error` в логе бота, за 30
# дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel лоссовый, с
# Beget чистый (Alertmanager там же шлёт без проблем) — поэтому продуктовый
# трафик Bot API идёт через маленький HTTP-ретранслятор на Beget
# (`ops/metrics/tg-relay`), а не напрямую.
#
# Пусто (дефолт) = прежнее поведение, прямой путь к api.telegram.org — это и
# есть механизм отката, если ретранслятор сам подведёт. При заданном адресе
# клиент (`app/services/tgbot/client.py`) всё равно делает одну попытку
# напрямую при транспортном отказе похода на ретранслятор — хуже прямого
# пути быть не должно ни при каких условиях.
# ENV: TELEGRAM_RELAY_BASE_URL, TELEGRAM_RELAY_SECRET.
telegram_relay_base_url: str = Field(default="", validation_alias="TELEGRAM_RELAY_BASE_URL")
telegram_relay_secret: str = Field(default="", validation_alias="TELEGRAM_RELAY_SECRET")
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.

View file

@ -1,14 +1,68 @@
from collections.abc import Generator
import asyncio
import logging
from collections.abc import Callable, Generator
from typing import Any
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings
logger = logging.getLogger(__name__)
# #3463. Потолок ОДНОГО statement'а, секунды·1000. Ставится на КОННЕКТЕ (libpq
# `options`), а не в питоновской обёртке: обёртка (`run_db_thread` ниже) при
# отмене обязана ДОЖДАТЬСЯ потока, иначе поток остаётся сиротой в общей
# `Session` — ровно то, ради чего писался #3449. Значит верхняя граница ожидания
# = длительность самого запроса, и задать её может только сервер.
#
# 30 с выбраны так, чтобы потолок НИКОГДА не стал биндящим ограничением для
# честной работы, но остался конечным:
# * самый длинный ОБЪЯВЛЕННЫЙ бюджет на `/estimate` — 20 с (`estimate_avito_imv_timeout_s`,
# config.py:852); дальше 12 с геокод, 8 с Yandex/Cian/house_meta. 30 с = 1.5× от максимума;
# * ОСНОВНАЯ опора по планировщику — `scrape_runs` (длительности целых прогонов, они
# не вытесняются): самая долгая ЧИСТО-БД задача за 14 суток — listing_source_snapshot,
# 9.7 с ЦЕЛИКОМ (и у неё сверх того свой `SET LOCAL statement_timeout = 900000`,
# который перекрывает это значение — гейт tests/test_3463_db_timeouts.py);
# * самый длинный set-based statement ЧЕРЕЗ движок из замеренных — матч ГАР→houses
# (`services/gar_flats_loader._MATCH_SQL`): 2.07 с с городским фильтром и 6.46 с без
# него (`city_filter=None`, флаг CLI). Запас ~3×, и это СЧИТАЮЩИЙ запрос, а не ждущий.
#
# `pg_stat_statements` опорой по планировщику НЕ является: при `max = 5000` он вытесняет
# редкие записи (проверено 12.09 — `dealloc` вырос на единицу за десять минут, и из топа
# пропали ВСЕ записи с `calls = 1`, включая `REFRESH MATERIALIZED VIEW` 30.85 с и KNN
# `cadastral_geo_match` 2.45 с). Суточная задача до следующих суток там не доживает, так
# что «самый долгий запрос 4.27 с» верно только для ВЫСОКОЧАСТОТНЫХ запросов.
_STATEMENT_TIMEOUT_MS = 30_000
# Ожидание БЛОКИРОВКИ — заведомо меньше: ждать лок дольше секунд смысла нет, лучше
# деградировать. 5 с — та же величина, что у миграций проекта
# (`SET LOCAL lock_timeout = '5s'` в data/sql/250,251,260,272,277…), снизу ограничена
# deadlock_timeout (на проде 1 с — сверено 12.09). Именно этот потолок закрывает
# сценарий #3463: под `ACCESS EXCLUSIVE` на `geocode_cache` запрос ЖДЁТ лок, а не
# считает, — statement_timeout тут только страховка от «считает вечно».
_LOCK_TIMEOUT_MS = 5_000
# idle_in_transaction_session_timeout НАМЕРЕННО не трогаем: тем же движком живёт tgbot,
# и `services/tgbot/bridge.py` держит транзакцию открытой ПОВЕРХ long-poll Telegram
# (замер на проде 12.09, 3 пробы с шагом 7 с: одна и та же сессия, запрос
# `SELECT value FROM tg_support_state …`, возраст транзакции циклически растёт до ~29 с).
# Сессионный потолок на простой в транзакции ронял бы long-poll КАЖДЫЙ цикл —
# гарантированно, а не в редком случае.
DB_CONNECT_ARGS = {
"options": f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
}
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
future=True,
# #3463. Накрывает ВСЕ три сервиса образа (backend / scraper / tgbot — один и тот
# же `app.core.db`, см. docker-compose.prod.yml) и обе стороны: продуктовый путь
# `/estimate` и задачи планировщика. Миграции идут мимо (psql из
# .forgejo/workflows/deploy-tradein.yml, не этот движок) — их DDL под своим
# `SET LOCAL lock_timeout` и потолком не ограничен.
connect_args=DB_CONNECT_ARGS,
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
@ -16,6 +70,41 @@ engine = create_engine(
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
# #3408 п.1. Потолок пула обязан быть НЕ МЕНЬШЕ суммы потолков одновременности,
# которые сам же процесс и объявляет: 4 оценки (`api/v1/trade_in`
# `_ESTIMATE_CONCURRENCY`) + 4 подсказки (`api/public/mera` `_SUGGEST_CONCURRENCY`)
# + 8 фоновых догрузок (`services/estimator` `_MAX_DEFERRED_REFRESH_TASKS`) = 16.
# Дефолт SQLAlchemy 5+10=15 меньше этой суммы, то есть исчерпать пул можно
# штатной работой, не абузом. Гейт — tests/test_3408_pool_ceiling.py.
#
# Инвариант «пул >= суммы объявленных потолков» — это ПОЛ, а не гарантия:
# сумма считает по одному коннекту на объявленную единицу работы, а коннект
# держит и всё остальное — любая ручка с `Depends(get_db)` тоже. Глобального
# обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет, поэтому на
# исчерпанном пуле соседние ручки отдают 500 (после ожидания `pool_timeout`),
# а не медленный 200.
#
# pool_size оставлен дефолтным (5): это ПОСТОЯННО открытые коннекты, а в покое
# прод держит 5-6 (замер 11.09). Растёт только overflow — коннекты пика,
# которые пул закрывает сам. Потолок процесса: 5 + 15 = 20; воркер один
# (docker-compose.prod.yml, uvicorn без --workers), Postgres max_connections=100.
max_overflow=15,
# #3408 п.2. Дефолтные 30 с ожидания коннекта длиннее ЛЮБОГО бюджета внешнего
# источника в эстиматоре: 8 с (Yandex / Cian / house_meta), 12 с (geocode),
# 20 с (Avito IMV — `estimate_avito_imv_timeout_s`, config.py:852). Сам чекаут
# прервать `asyncio.wait_for` не может: он занимает поток `asyncio.to_thread`
# целиком, а пул потоков конечен (min(32, cpu+4)) — исчерпанный пул коннектов
# так превращается в исчерпанный пул потоков. 5 с короче самого КОРОТКОГО
# бюджета: занятый пул деградирует ОДИН источник, а не весь запрос.
#
# Отдельный коммит в конце ветки намеренно (ревью PR #3444): это единственная
# правка, которая меняет режим отказа с «медленно» на «быстро с ошибкой», и
# едет она во ВСЕ сервисы образа — backend, scraper, tgbot
# (tradein-mvp/docker-compose.prod.yml). За 29 ч логов исчерпания пула не было
# ни разу, то есть новое значение на проде пока не на чем проверить.
# ТРИГГЕР ОТКАТА на 30 с: любое `QueuePool limit ... timed out` в логах
# бэкенда ЛИБО рост failed+zombie в `scrape_runs` после деплоя.
pool_timeout=5,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
@ -30,3 +119,39 @@ def get_db() -> Generator[Session, None, None]:
yield db
finally:
db.close()
async def run_db_thread[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""`asyncio.to_thread(fn, ...)`, который при отмене ДОЖИДАЕТСЯ своего потока (#3449).
Для синхронной работы по сессии, которая шагу НЕ принадлежит она общая со
всем остальным запросом (`Depends(get_db)`). Поток отменить нельзя: у
`asyncio.to_thread` отменяется только ожидание со стороны loop'а. Корутина
умирает по бюджету источника (`estimator._with_budget` = `asyncio.wait_for`,
геокодер 12 с), а поток продолжает работать с ТОЙ ЖЕ `Session`, пока
вызывающий уже идёт дальше по коду следующий источник,
`_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной
`Session` дают «another operation is in progress» / `InvalidRequestError` на
СЛЕДУЮЩЕМ шаге: у источников такую ошибку глушит `except` вокруг вызова, у
персиста оценки не глушит ничего 500 и потерянная оценка клиента.
Поэтому отмена пробрасывается ПОСЛЕ того, как поток отпустил сессию. Цена
бюджет источника переезжает на длину ОДНОГО шага БД (чекаут `pool_timeout`
плюс сам запрос), а не на длину фетча, ради которой бюджет и заведён.
Только защита от сироты: транзакцию шаг НЕ завершает (в середине геокодинга
commit зафиксировал бы частичное состояние оценки). Кому нужен ещё и возврат
коннекта в пул перед внешним HTTP `estimator._db_step`, он поверх этого.
Гейт tests/test_3449_geocoder_cancel_orphan.py.
"""
step = asyncio.ensure_future(asyncio.to_thread(fn, *args, **kwargs))
try:
return await asyncio.shield(step)
except asyncio.CancelledError:
await asyncio.wait([step])
if not step.cancelled() and step.exception() is not None:
# Результата уже никто не ждёт: без явного чтения asyncio напечатает
# «Task exception was never retrieved» вообще без контекста.
logger.warning("шаг БД упал уже после отмены: %s", step.exception())
raise

View file

@ -41,17 +41,84 @@ def scrub_query_secrets(text: str) -> str:
class QuerySecretFilter(logging.Filter):
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан.
Скрабит `record.msg` и КАЖДЫЙ элемент `record.args` по отдельности НЕ схлопывает
их в единую строку через `record.getMessage()` с последующим `args = ()`.
Прод-баг (#3471): схлопывание ломало `uvicorn.access` — там `record.args` это
структурный 5-tuple `(client_addr, method, full_path, http_version, status_code)`,
который `uvicorn.logging.AccessFormatter.formatMessage()` распаковывает напрямую
(`a, b, c, d, e = record.args`), в обход `record.getMessage()`. Как только фильтр
находил секрет (например `?secret=` в webhook-пути) и обнулял `args`, форматтер падал
с `ValueError: not enough values to unpack (expected 5, got 0)` сама попытка
заскрабить секрет ломала запись лога целиком (`--- Logging error ---` в докер-логах).
"""
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
scrubbed = scrub_query_secrets(message)
if scrubbed != message:
record.msg = scrubbed
record.args = ()
args = record.args
if isinstance(args, tuple) and args:
# Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем.
# `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах
# этого кодбейза секрет+query-контекст лежат САМОДОСТАТОЧНО внутри одного
# аргумента (например body_preview в app/services/dadata.py), а не расщеплены
# между текстом шаблона и голым значением — трогать msg тут не нужно и опасно
# (шаблонный `%s` сам по себе мог бы ложно совпасть с чувствительным именем
# параметра прямо перед ним).
scrubbed_args = tuple(
scrub_query_secrets(a) if isinstance(a, str) else a for a in args
)
if scrubbed_args != args:
record.args = scrubbed_args
elif isinstance(record.msg, str):
# Args нет — вся запись уже готовым текстом в msg (f-string и т.п.).
scrubbed_msg = scrub_query_secrets(record.msg)
if scrubbed_msg != record.msg:
record.msg = scrubbed_msg
return True
# ── Секреты в произвольном тексте (тело ответа внешнего API) — #3471 ──────────
#
# DaData на HTTP 403 возвращает диагностику вида:
# "Feature 'CLEAN' disabled for token '<действующий 40-символьный токен>'. See ..."
# body_preview из этого текста уходит в WARNING/ERROR лог (app/services/dadata.py) →
# docker logs → потенциально breadcrumb к любой последующей ошибке в GlitchTip.
# Маскируем ДО логирования. Не завязываемся на конкретную формулировку вендора —
# она может измениться (см. generic-слой ниже).
_TOKEN_QUOTED = re.compile(r"(\btoken\s*['\"])([^'\"]+)(['\"])", re.IGNORECASE)
# Любая hex/base64-подобная последовательность 24+ символов — ловит секрет независимо
# от контекста (Authorization/X-Secret значения, если когда-нибудь попадут в текст как есть).
_LONG_SECRET_LIKE = re.compile(r"[A-Za-z0-9+/_-]{24,}")
def _mask_value(value: str) -> str:
"""`8d4e…(40)` — первые 4 символа + длина в скобках, остальное скрыто."""
if len(value) <= 4:
return "***"
return f"{value[:4]}…({len(value)})"
def scrub_body_secrets(text: str | None) -> str:
"""Маскирует токены/секреты в произвольном тексте (тело ответа внешнего API и т.п.).
Двухслойно: 1) явный ``token '<value>'`` (текущая формулировка DaData на 403),
2) generic любая hex/base64-подобная последовательность 24+ символов, чтобы защита
не зависела от того, как вендор сформулирует сообщение завтра.
"""
if not text:
return text or ""
def _replace_quoted(m: re.Match[str]) -> str:
return f"{m.group(1)}{_mask_value(m.group(2))}{m.group(3)}"
scrubbed = _TOKEN_QUOTED.sub(_replace_quoted, text)
scrubbed = _LONG_SECRET_LIKE.sub(lambda m: _mask_value(m.group(0)), scrubbed)
return scrubbed
def install_query_secret_filter(*logger_names: str) -> None:
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.

View file

@ -161,6 +161,18 @@ class SlidingWindowLimiter:
del self._hits[k]
return len(bucket)
def reset(self, key: str) -> None:
"""Обнуляет окно под *key*.
Нужен вызывающим, которые считают не «сколько сделано», а «сколько ПОДРЯД»
например счётчик отказов отправки (`support.py:_send_failure_limiter`):
успешная отправка означает, что канал жив, и предыдущие отказы больше не
должны приближать cooldown. Для лимитеров-бюджетов (`_send_limiter`,
`_anon_ip_limiter`, `_notify_limiter`) не используется там обнуление
по запросу было бы дырой в самом лимите.
"""
self._hits.pop(key, None)
def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
для вызывающих, которым не нужно различать "попытка"/"успех" (см.

View file

@ -19,6 +19,7 @@ from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from sentry_sdk.types import Event, Hint
from app.api.public import mera as public_mera
from app.api.v1 import (
@ -50,6 +51,7 @@ from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
from app.observability import metrics as app_metrics
from app.observability.sentry_scrub import scrub_pii_event
from app.services.tgbot.shared import close_telegram_client, init_telegram_client
logger = logging.getLogger(__name__)
@ -78,31 +80,43 @@ install_query_secret_filter()
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_public_address,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
TelegramClient в стек-фреймах при ошибке sendMessage, а
def _before_send(event: Event, hint: Hint) -> Event | None:
"""Композиция payments-disabled drop + платёжный body-wipe + PII-scrub +
Telegram bot-токен redaction + RetryError fingerprint-стабилизация
(#tgsupport-web, PR-D2, glitchtip-noise, #3471) — см.
app/tgbot_main.py._before_send (идентичная композиция без последнего
шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже
держит TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты.
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные
режет `request.data` целиком только для `/payments/*`, остальные пути
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
(before_send и before_send_transaction) вчерашний баг в Птице закрыл
только error-канал, transaction-канал остался вообще без обработчика.
#3471: payments-disabled drop идёт ПЕРВЫМ шагом — это единственный
процесс из трёх entrypoint'ов, который реально держит ASGI-роут
`/api/v1/payments/*`, поэтому именно здесь события возникают; ранний
return None экономит остальную композицию на заведомо отбрасываемом
событии.
PR-D2: платёжный body-wipe идёт следующим шагом, а не заменяет
остальные режет `request.data` целиком только для `/payments/*`,
остальные пути (extra/contexts/traceback) по-прежнему проходят
ключ-based scrub и token-redaction. Тот же обработчик передан ОБОИМ
каналам ниже (before_send и before_send_transaction) вчерашний баг в
Птице закрыл только error-канал, transaction-канал остался вообще без
обработчика.
RetryError-стабилизация этот процесс обслуживает /api/v1/geocode/*
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
@ -216,7 +230,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
# this API process never actually launched scheduler_loop() in production.
yield
# Общий на приложение Telegram-клиент (#tg-connection-resilience): ручки
# support/glitchtip раньше создавали его на КАЖДЫЙ запрос, то есть каждое
# зеркалирование сообщения начиналось с полного TCP+TLS-хендшейка до
# api.telegram.org. Один пул keep-alive на процесс, закрываем на shutdown.
# Без токена не создаём: ручки в этом случае и так отвечают 503.
if settings.telegram_bot_token:
init_telegram_client()
try:
yield
finally:
await close_telegram_client()
app = FastAPI(

View file

@ -99,6 +99,47 @@ BUILD_INFO = Gauge(
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1)
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
#
# Источник списка — реальные `event_type` из `user_events` (миграция 184) плюс
# ручки, которые сами в этот аудит-лог не пишут (suggest, PDF-экспорт). Метки
# везде — фиксированный литерал из кода вызова (outcome/found/channel/result),
# НЕ значение из запроса: username, адрес, estimate_id в метку не идут —
# это ровно то, что взрывает кардинальность ряда у Prometheus.
ESTIMATES = Counter(
"mera_estimates_total",
"Запрошенных оценок trade-in, по исходу расчёта",
labelnames=("outcome",), # ok | insufficient_data
)
ADDRESS_SUGGESTIONS = Counter(
"mera_address_suggestions_total",
"Запросов автокомплита адреса (/geocode/suggest), нашёлся ли результат",
labelnames=("found",), # yes | no
)
REPORTS_EXPORTED = Counter(
"mera_reports_exported_total",
"Скачанных PDF-отчётов по оценке trade-in",
)
LEADS = Counter(
"mera_leads_total",
"Сохранённых контактных заявок (телефон + согласие) с результата оценки",
)
SUPPORT_MESSAGES = Counter(
"mera_support_messages_total",
"Сообщений в поддержку, дошедших до Telegram-топика, по каналу",
labelnames=("channel",), # web | anon
)
LOGINS = Counter(
"mera_logins_total",
"Попыток входа в личный кабинет, по исходу",
labelnames=("result",), # success | failed
)
def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.

View file

@ -25,6 +25,7 @@ import re
from typing import Any
from sentry_sdk.types import Event
from starlette.exceptions import HTTPException as _StarletteHTTPException
from tenacity import RetryError
_REDACTED = "[REDACTED]"
@ -256,6 +257,41 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
return event
_PAYMENTS_DISABLED_DETAIL = "payments are disabled"
def drop_payments_disabled_event(event: Event, hint: dict[str, Any]) -> Event | None:
"""before_send-хук: роняет 503 "payments are disabled" из
`payments.py._require_enabled` (issue #3471, GlitchTip-группа TRADE-IN-3GG).
Источник внутренний IP смоук-проверки: кнопки оплаты во фронте нет,
клиентского трафика на эти пути нет вообще, а выключенный платёжный контур
(`settings.payments_enabled=False`) штатно отвечает 503 на каждый такой
запрос 167 событий за 29.08-12.09 размывали ленту, на этом фоне терялась
настоящая ошибка. Само поведение ручки НЕ меняется (503 остаётся)
фильтруется только репортинг в трекер: sentry_sdk `StarletteIntegration`
репортит любой `HTTPException` с кодом из `failed_request_status_codes`
(по умолчанию весь диапазон 5xx) как error-событие, даже когда исключение
штатно обработано FastAPI и превращено в корректный HTTP-ответ.
Матчим `isinstance` реального объекта исключения из `hint["exc_info"]` (тот
же контракт, что `stabilize_retry_error_fingerprint` ниже) + точный текст
`detail` НЕ код 503 сам по себе, чтобы не проглотить другие 503 (напр.
будущий maintenance-режим другого роутера).
"""
if not isinstance(event, dict):
return event
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
if (
isinstance(exc_value, _StarletteHTTPException)
and exc_value.status_code == 503
and exc_value.detail == _PAYMENTS_DISABLED_DETAIL
):
return None
return event
_PUBLIC_API_URL_SEGMENT = "/api/public/"
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.

View file

@ -43,14 +43,16 @@ if settings.glitchtip_dsn:
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.types import Event, Hint
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
scrub_payment_request_body,
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
def _before_send(event: Event, hint: Hint) -> Event | None:
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда belt-and-
@ -58,6 +60,13 @@ if settings.glitchtip_dsn:
`request`/`extra`. Тот же обработчик на оба канала ниже см.
app/main.py._before_send (идентичный мотив, не дублировать без причины).
#3471: payments-disabled drop — тот же belt-and-suspenders мотив, что и
payment body-wipe выше по докстрингу: этот процесс сегодня не отвечает
503 из `_require_enabled` (нет ASGI/роутов), реальный источник шума
`app/main.py`, но фильтр держим одинаковым во всех трёх entrypoint'ах,
чтобы поведение не разошлось, если payments-код когда-нибудь переедет
сюда же.
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
следом за платёжным body-wipe: этот процесс гоняет
`geocode_missing_listings` (ночной batch, сотни адресов за прогон)
@ -66,13 +75,16 @@ if settings.glitchtip_dsn:
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
См. sentry_scrub docstring.
"""
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint)
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return stabilize_retry_error_fingerprint(scrubbed, hint)
return stabilize_retry_error_fingerprint(scrubbed, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,

View file

@ -158,10 +158,14 @@ class DkpCorridor(BaseModel):
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
линия «коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит
за [low,high]×slack добавляется текстовая пометка. НЕ хард-клампит оценку.
rooms + площади ±15% за период. Показывается как тонкая референсная линия
«коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит за
[low,high]×slack добавляется текстовая пометка.
None / count=0 если по улице нет сопоставимых сделок.
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
"""
count: int # число ДКП-сделок в выборке
@ -183,6 +187,30 @@ class DkpCorridor(BaseModel):
# None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None
@computed_field # type: ignore[prop-decorator]
@property
def advisory_only(self) -> bool:
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=3..9 на экране была неотличима от работающей.
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.
Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а
не про то, что цену коридор не трогал.
Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом.
"""
from app.core.config import settings # локально: schemas остаётся import-light
return self.count < settings.estimate_corridor_clamp_min_n
class PriceTrendPoint(BaseModel):
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).

View file

@ -131,7 +131,7 @@ async def backfill_cian_price_history(
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
# логом, который читается как «Циан нас блокирует».
logger.error(
logger.warning(
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
"на listing_id=%s (обработано %d из %d)",
exc,

View file

@ -201,7 +201,7 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
logger.error(
logger.warning(
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
"cookies НЕ помечены протухшими, retry на следующем такте",
exc,

View file

@ -29,6 +29,7 @@ from typing import Any
import httpx
from app.core.config import settings
from app.core.log_scrub import scrub_body_secrets
logger = logging.getLogger(__name__)
@ -173,7 +174,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
return None
if status in (401, 403):
body_preview = (response.text or "")[:200]
body_preview = scrub_body_secrets(response.text)[:200]
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
@ -213,7 +214,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP %d — transient server error", status)
return None
if status >= 400:
body_preview = (response.text or "")[:200]
body_preview = scrub_body_secrets(response.text)[:200]
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
return None
@ -436,7 +437,7 @@ async def suggest_addresses(
logger.warning("dadata suggest: HTTP %d — transient server error", status)
return []
if status >= 400:
body_preview = (response.text or "")[:200]
body_preview = scrub_body_secrets(response.text)[:200]
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
return []

View file

@ -56,7 +56,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings
from app.core.db import SessionLocal
from app.core.db import SessionLocal, run_db_thread
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
@ -328,6 +328,13 @@ SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд р
_SBER_REGION_SERIES: dict[int, str] = {
66: SBER_TIME_ADJUST_REGION,
77: "Москва",
# Московская область — свой ряд, а не фолбэк на «Россию». Загрузчик научился
# тянуть REF_AREA=50 (services/sber_index), и 12.09.2026 ряд приехал на прод:
# real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же глубина,
# что у Москвы и обл.66. До этой строки область считалась по общероссийскому
# ряду, то есть коридор ошибался на расхождение области со страной; теперь
# ошибка только внутрирегиональная.
50: "Московская область",
}
# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0):
@ -661,8 +668,10 @@ def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | No
# within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное
# обусловливание дают per-rooms blend + захардженный hedonic (год+площадь).
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
# Cache key: rooms bucket (единственный legacy per-rooms путь после #2002).
_asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {}
# Cache key: (region_code, rooms bucket) — #3512. Регион — часть ключа с миграции 304
# (asking_to_sold_ratios ключуется по (region_code, rooms_bucket, district)); один
# бакет теперь несёт РАЗНЫЕ ratio для разных регионов, общий int-ключ смешал бы их.
_asking_sold_ratio_cache: dict[tuple[int, int], tuple[float | None, str | None, float]] = {}
def _get_asking_sold_ratio(
@ -670,6 +679,7 @@ def _get_asking_sold_ratio(
rooms: int | None,
area_m2: float | None = None,
anchor_ppm2: float | None = None,
region_code: int = regions_mod.DEFAULT_REGION_CODE,
) -> tuple[float | None, str | None]:
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
@ -685,9 +695,24 @@ def _get_asking_sold_ratio(
rooms-ключе vs area-ключе. area_bucket() тот же Python-двойник, что и в
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка
(WHERE rooms_bucket = bucket AND district = '') fallback на global -1
(basis='global_fallback'). Ничего нет (None, None).
#3512: region_code — регион ЗАПРОСА (target_region_code резолвера, см.
_ratio_resolver ниже), НЕ дефолт 66 всегда. Раньше ratio считался ИСКЛЮЧИТЕЛЬНО
по ЕКБ и применялся к любому адресу, включая Москву (region_code=77) коэффициент
рынка на порядок дешевле (медиана ДКП: 66 90 278 /м², 77 322 976 /м²,
замер 13.09.2026) домножал московскую asking-цену. Дефолт-параметр (66) сохранён
ради обратной совместимости позиционных вызовов (тот же идиом, что
`_is_plausible_deal(region_code: int = regions_mod.DEFAULT_REGION_CODE)`).
Запрос к asking_to_sold_ratios (migration 080 + 304): per-rooms строка СВОЕГО
региона (WHERE region_code = :region_code AND rooms_bucket = bucket AND
district = '') fallback на global -1 ТОГО ЖЕ региона (basis='global_fallback').
Ни одной строки для региона (регион ещё не накопил 30/30 сделок/listings, см.
app/tasks/asking_to_sold_ratio.py) явная деградация (None, None) + INFO-лог
та же идиома, что sber_region_series_name делает для отсутствующего в карте
региона, но БЕЗ подстановки чужого регионального ratio: значение другого рынка
здесь не нейтральный фолбэк (в отличие от общероссийского sber-ряда), а именно
та ошибка калибровки, которую #3512 устраняет. ratio_basis в ответе честно
остаётся None сайту не приписывается ЧУЖОЙ региональный коэффициент.
anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер
вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций см.
@ -695,11 +720,12 @@ def _get_asking_sold_ratio(
статистический артефакт (#2002, см. module-level комментарий выше).
Таблицы нет / любая ошибка (None, None), НЕ raise (graceful).
Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
Кэшируется на ключ (region_code, bucket) с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
"""
bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
cache_key = (region_code, bucket)
cached = _asking_sold_ratio_cache.get(bucket)
cached = _asking_sold_ratio_cache.get(cache_key)
if cached is not None:
ratio, basis, fetched = cached
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
@ -723,24 +749,39 @@ def _get_asking_sold_ratio(
text(
"""
SELECT ratio, basis FROM asking_to_sold_ratios
WHERE rooms_bucket = CAST(:b AS int) AND district = ''
WHERE region_code = CAST(:region_code AS int)
AND rooms_bucket = CAST(:b AS int) AND district = ''
"""
),
{"b": bucket},
{"region_code": region_code, "b": bucket},
).fetchone()
if row is None:
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1).
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1)
# ТОГО ЖЕ региона (#3512 — никогда чужого региона).
row = db.execute(
text(
"""
SELECT ratio, basis FROM asking_to_sold_ratios
WHERE rooms_bucket = -1 AND district = ''
WHERE region_code = CAST(:region_code AS int)
AND rooms_bucket = -1 AND district = ''
"""
),
{"region_code": region_code},
).fetchone()
if row is not None and row.ratio is not None:
ratio = float(row.ratio)
basis = row.basis
elif row is None:
# #3512: у региона нет НИ ОДНОЙ строки (ни per-rooms, ни global -1) —
# обычно потому, что регион ещё не набрал 30/30 сделок/listings
# (см. app/tasks/asking_to_sold_ratio.py). Явная деградация, не
# подмена чужим регионом.
logger.info(
"asking_to_sold_ratio: region_code=%d нет ни одной строки "
"(bucket=%d) — sold-коррекция не применяется для этого региона",
region_code,
bucket,
)
ok = True
break
except Exception as exc:
@ -769,7 +810,7 @@ def _get_asking_sold_ratio(
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
# уже вернулся ранним return и сюда не доходит.
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic())
_asking_sold_ratio_cache[cache_key] = (ratio, basis, time.monotonic())
return ratio, basis
@ -788,6 +829,46 @@ YANDEX_VALUATION_DEFAULT_CATEGORY = "APARTMENT"
YANDEX_VALUATION_DEFAULT_TYPE = "SELL"
async def _db_step[T](db: Session, work: Callable[[], T]) -> T:
"""Синхронный шаг БД внутри async-функции: в потоке, с возвратом коннекта и без сирот.
Три вещи сразу, потому что все про одно и то же `Session` из `get_db`
(#3408 п.1/п.2):
1. `db.execute()` блокирующий вызов. На loop'е он держит ВЕСЬ инстанс
(один воркер uvicorn, #3083) на время чекаута коннекта: при занятом пуле
это `pool_timeout` секунд, и никакой `asyncio.wait_for` его не прервёт.
В потоке ждёт поток, а loop продолжает обслуживать остальных.
2. `commit()`/`rollback()` в конце не про данные, а про КОННЕКТ: сессия
отдаёт его в пул только на завершении транзакции. Без этого коннект,
взятый ради одного SELECT'а кэша, живёт до конца функции — включая
ожидание чужого HTTP (замер 11.09: 8.5 с на догрузку Яндекса), и пиковый
спрос считается не в коротких SELECT'ах, а в целых фетчах.
3. Отмена шага ДОЖИДАЕТСЯ потока это `run_db_thread` (app/core/db.py,
#3449), общий для эстиматора и геокодера. Поток отменить нельзя, а
сессия у шага не своя общая со всем остальным запросом.
ЧТО ЭТО МЕНЯЕТ ДЛЯ ВЫЗЫВАЮЩЕГО: незакоммиченная работа его транзакции
(например fill-null backfill ФИАСа в `estimate_quality`) фиксируется здесь,
а не в первом commit'е ниже по коду. Новым классом поведения это не
является: обе функции-источника и так коммитят ЧУЖУЮ сессию сразу после
фетча (`save_imv_evaluation`, UPSERT в `external_valuations`) сдвигается
момент, а не факт. Ошибка внутри `work` `rollback` + проброс наверх, как
и раньше: гасят её существующие `except` вокруг вызова.
"""
def _run() -> T:
try:
result = work()
except Exception:
db.rollback()
raise
db.commit()
return result
return await run_db_thread(_run)
async def _get_or_fetch_imv_cached(
db: Session,
*,
@ -824,10 +905,12 @@ async def _get_or_fetch_imv_cached(
has_loggia,
)
existing = (
db.execute(
text(
"""
existing = await _db_step(
db,
lambda: (
db.execute(
text(
"""
SELECT id, cache_key, address, rooms, area_m2, floor, floor_at_home,
house_type, renovation_type, has_balcony, has_loggia,
lat, lon, geo_hash, avito_address_id, avito_location_id,
@ -840,11 +923,12 @@ async def _get_or_fetch_imv_cached(
ORDER BY fetched_at DESC
LIMIT 1
"""
),
{"ck": cache_key, "ttl_hours": IMV_CACHE_TTL_HOURS},
)
.mappings()
.first()
),
{"ck": cache_key, "ttl_hours": IMV_CACHE_TTL_HOURS},
)
.mappings()
.first()
),
)
if existing is not None:
@ -901,7 +985,9 @@ async def _get_or_fetch_imv_cached(
# release в finally на всех выходах (исключение/таймаут — тоже).
proxy_provider=RealProxyProvider(),
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
await _db_step(
db, lambda: save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
)
logger.info(
"imv: fresh recommended=%d range=(%d, %d) count=%d",
result.recommended_price,
@ -935,7 +1021,9 @@ async def _get_or_fetch_imv_cached(
config=RealScraperConfig(),
proxy_provider=RealProxyProvider(), # #3386, см. первый вызов выше
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
await _db_step(
db, lambda: save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
)
logger.info(
"imv: retry OK recommended=%d range=(%d, %d) count=%d",
result.recommended_price,
@ -988,6 +1076,19 @@ _DEFERRED_REFRESH_TASKS: set[asyncio.Task[None]] = set()
# Подобран под текущий прод: 1 воркер uvicorn, mem_limit 768m, max_connections
# 100 у Postgres. Догрузка почти всё время ждёт чужой HTTP, поэтому небольшой
# потолок не занижает пропускную способность прогрева.
#
# Фоновых источника ДВА, и по удержанию коннекта они РАЗНЫЕ (#3408):
# • yandex_valuation — коннект только на самих SELECT/UPSERT кэша (`_db_step`):
# до правки одна догрузка держала соединение 8.5 с (замер на проде 11.09),
# то есть восемь таких задач выедали пул целиком;
# • cian_valuation — коннект на ВЕСЬ фетч (HTTP-таймаут 25 с): транзакцию
# открывают `_load_from_cache` / `load_session` на входе, а закрывает её
# `db.commit()` в самом конце (scraper_kit providers/cian/valuation.py:163,
# 176, 595). Известный остаток: функция общая со скраппером
# (`app/tasks/cian_history_backfill.py:458`), где коммит в середине менял бы
# семантику батча, — нужен отдельный опт-ин путь, а не правка на месте.
# На ПОТОЛОК пула остаток не влияет (задача держит один коннект независимо от
# того, как долго) — только на среднюю занятость пула.
_MAX_DEFERRED_REFRESH_TASKS = 8
@ -1069,10 +1170,12 @@ async def _get_or_fetch_yandex_valuation_cached(
# Cache lookup
try:
cached = (
db.execute(
text(
"""
cached = await _db_step(
db,
lambda: (
db.execute(
text(
"""
SELECT raw_payload, fetched_at
FROM external_valuations
WHERE source = 'yandex_valuation'
@ -1081,11 +1184,12 @@ async def _get_or_fetch_yandex_valuation_cached(
ORDER BY fetched_at DESC
LIMIT 1
"""
),
{"ck": cache_key},
)
.mappings()
.first()
),
{"ck": cache_key},
)
.mappings()
.first()
),
)
except Exception as e:
logger.warning("yandex_valuation: cache lookup failed: %s", e)
@ -1159,9 +1263,11 @@ async def _get_or_fetch_yandex_valuation_cached(
# Save to cache (UPSERT on (source, cache_key))
try:
db.execute(
text(
"""
await _db_step(
db,
lambda: db.execute(
text(
"""
INSERT INTO external_valuations (
source, cache_key, address,
house_id,
@ -1181,24 +1287,25 @@ async def _get_or_fetch_yandex_valuation_cached(
fetched_at = NOW(),
expires_at = NOW() + (:ttl_hours || ' hours')::interval
"""
),
{
"ck": cache_key,
"addr": address,
"hid": house_id,
"payload": json.dumps(result.model_dump(mode="json"), ensure_ascii=False),
"ttl_hours": YANDEX_VALUATION_CACHE_TTL_HOURS,
},
),
{
"ck": cache_key,
"addr": address,
"hid": house_id,
"payload": json.dumps(result.model_dump(mode="json"), ensure_ascii=False),
"ttl_hours": YANDEX_VALUATION_CACHE_TTL_HOURS,
},
)
db.commit()
logger.info(
"yandex_valuation: fresh fetch saved key=%s items=%d",
cache_key[:8],
len(result.history_items),
)
except Exception as e:
# Откат уже сделан в потоке (`_db_step` → `except` → `db.rollback()`), а здесь
# это был бы блокирующий вызов на event loop'е — ровно то, что правка убирает.
logger.warning("yandex_valuation: cache save failed (continuing): %s", e)
db.rollback()
return result
@ -1925,6 +2032,38 @@ def _resolve_target_city(address_text: str | None) -> str | None:
return None
def region_code_for_address(address: str | None) -> int:
"""Код региона покрытия (regions.REGIONS) по городу в адресе.
Режет адрес по запятым, каждый сегмент strip+lower, ищет ТОЧНОЕ
совпадение с одним из городов региона (regions.region_by_city). Точное
равенство сегмента, а НЕ подстрока: подстрочный поиск "москва" в адресе
поймал бы екатеринбургскую "Московская улица" и увёл её в регион 77
сегмент "московская улица" целиком "москва".
#dkp-corridor-scope (2026-09-12): нужен для WHERE region_code = ... в
get_street_deals/get_sales_vs_listings extract_street_name после фикса
московского порядка ("Название улица") стал возвращать имя улицы и для
Москвы, и без явного region-скоупа одноимённая улица из другого региона
(66 vs 77) подмешивалась бы в медиану (напр. "Ясная": 168 сделок в 66 и
80 в 77, "Советская": 1202 и 17).
Returns regions.DEFAULT_REGION_CODE (66), если ни один сегмент не
распознан как город ни одного региона байт-в-байт прежнее поведение
(все ЕКБ/безымянные адреса и раньше трактовались как регион 66).
"""
if not address:
return regions_mod.DEFAULT_REGION_CODE
for raw_segment in address.split(","):
segment = raw_segment.strip()
if not segment:
continue
region = regions_mod.region_by_city(segment)
if region:
return region.code
return regions_mod.DEFAULT_REGION_CODE
def _fetch_dkp_corridor(
db: Session,
*,
@ -1939,8 +2078,13 @@ def _fetch_dkp_corridor(
"""#652: коридор ₽/м² по реальным ДКП-сделкам Росреестра для target.
Reuse паттерна street-deals (api/v1/trade_in.py): извлекаем улицу из адреса,
фильтруем `deals` (source='rosreestr', та же rooms, площадь ±tolerance, окно
фильтруем `deals` (source='rosreestr', площадь ±tolerance, окно
period_months) и нормализуем per-. Возвращаем low/median/high /м².
Фильтра по rooms тут НЕТ осознанно (#3256): `deals.rooms` — бакет площади,
а не комнатность, поэтому предикат по нему был вторым фильтром по площади
и вырезал полосу ±tolerance (у 180 клиентов целиком). См. якорь в
deploy/import-rosreestr.sh. `rooms` в сигнатуре остаётся как гейт
«запрос без комнатности не оценка», но в SQL не уходит.
ADVISORY caller не клампит, только сурфейсит + опциональная пометка.
Best-effort: None при отсутствии улицы / сделок / любой ошибке.
NB: при n>=clamp_min_n коридор клампит headline + radius-floor (НЕ чисто
@ -1970,6 +2114,34 @@ def _fetch_dkp_corridor(
return None
area_min = area * (1.0 - area_tolerance)
area_max = area * (1.0 + area_tolerance)
# #3256: предиката по `d.rooms` здесь НЕТ НАМЕРЕННО — и это не послабление
# фильтра, а снятие ВТОРОГО фильтра по площади.
#
# `deals.rooms` — не комнатность, а синтетика из площади: Росреестр комнатность
# не отдаёт, import-rosreestr.sh пишет в эту колонку тот же CASE 30/44/62/85, что
# и `asking_to_sold_ratio.area_bucket()`. Прод-замер 2026-09-11: 321 559 из
# 321 560 сделок удовлетворяют `rooms == area_bucket(area_m2)`, max(rooms) = 4.
# Значит `d.rooms = X` тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`, т.е.
# ЛЮБОЙ предикат по rooms — это ещё одна полоса по площади поверх `area_m2
# BETWEEN :area_min AND :area_max` ниже, режущая её по границам бакета.
#
# Замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12), доля полосы
# ±15%, которая переживает предикат:
# d.rooms = комнаты клиента → медиана 77.8%, у 180 клиентов ОТ ПОЛОСЫ НЕ
# ОСТАЁТСЯ НИЧЕГО (пересечение пусто ⇒ коридор невозможен в принципе:
# «3 комнаты, 100 м²» → [85,115] ∩ bucket3[62,85) = ∅; у 5+ комнат — всегда,
# в deals.rooms нет значения > 4);
# d.rooms = area_bucket(area) → медиана 90.0%, ноль пустых, но у 902 из 1179
# полоса всё ещё усечена — ровно там, где площадь клиента легла близко к
# границе бакета (44.0 м² → сохраняется 50% полосы), т.е. величина усечения
# определяется не моделью, а случайным положением метража;
# без предиката → 100% по построению.
# Коридор строится по «похожим по площади сделкам на этой улице», и полоса ±15%
# уже ровно это и выражает — второй ступенчатый фильтр поверх неё только портит
# выборку. Историю с промежуточным вариантом `area_bucket` см. в #3256.
#
# `rooms` в сигнатуре ОСТАЁТСЯ: он всё ещё гейтит вызов (запрос без комнатности —
# не оценка) и не даёт молча посчитать коридор на полупустом payload.
# #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана
# быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case,
# словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД
@ -1990,7 +2162,7 @@ def _fetch_dkp_corridor(
AND d.region_code = CAST(:region_code AS int)
AND d.address ILIKE :street_pattern
AND d.address ~* :street_regex
AND d.rooms = CAST(:rooms AS integer)
-- #3256: фильтра по d.rooms нет — см. комментарий выше
AND d.area_m2 BETWEEN :area_min AND :area_max
AND d.deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval
@ -2008,7 +2180,6 @@ def _fetch_dkp_corridor(
{
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -2051,6 +2222,11 @@ def _fetch_dkp_corridor(
if dd is not None and (latest_deal is None or dd > latest_deal):
latest_deal = dd
ppm2_values = sorted(adjusted)
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
scope = "street"
# #oblast-D widen: a single street in a small non-EKB town can easily have
# <3 (or 0) ДКП deals in the last 12 months even though the CITY overall
@ -2077,7 +2253,7 @@ def _fetch_dkp_corridor(
AND d.region_code = CAST(:region_code AS int)
AND d.city IS NOT NULL
AND LOWER(d.city) = CAST(:target_city AS text)
AND d.rooms = CAST(:rooms AS integer)
-- #3256: фильтра по d.rooms нет — см. комментарий выше
AND d.area_m2 BETWEEN :area_min AND :area_max
AND d.deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval
@ -2087,7 +2263,6 @@ def _fetch_dkp_corridor(
),
{
"target_city": city.lower(),
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -2127,6 +2302,7 @@ def _fetch_dkp_corridor(
city,
)
ppm2_values = sorted(city_adjusted)
scope = "city_wide"
# #2846: коридор теперь описывает city-выборку — и возраст обязан
# переехать вместе с числами, иначе подпись осталась бы от street-
# выборки, которую на витрине уже никто не видит.
@ -2154,6 +2330,7 @@ def _fetch_dkp_corridor(
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
"period_months": period_months,
"latest_deal_date": latest_deal,
"scope": scope,
}
@ -2995,6 +3172,12 @@ async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any:
blowing the gateway read timeout (#654: opaque Caddy 502/504).
budget_s <= 0 disables the guard (await directly) escape hatch via config.
НЕ ВКЛАДЫВАТЬ бюджеты друг в друга: защита `run_db_thread` (#3449) одноразовая
она ловит ОДНУ отмену, а вторая, прилетевшая пока шаг БД дожидается своего
потока, вылетает из самого ожидания, и поток остаётся сиротой в чужой `Session`
(ровно то, ради чего защита и заведена). Сейчас ни один вызов `_with_budget` не
обёрнут другим это инвариант, а не совпадение.
"""
if budget_s is None or budget_s <= 0:
return await coro
@ -4302,7 +4485,7 @@ async def estimate_quality(
if geo is None:
# Без координат не можем искать через PostGIS. Возвращаем low confidence.
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
return await asyncio.to_thread(
return await run_db_thread(
_empty_estimate,
payload,
db,
@ -4333,7 +4516,7 @@ async def estimate_quality(
dadata_fias = dadata.house_fias_id if dadata else None
match_fias = payload.target_fias_id or dadata_fias
try:
match = await asyncio.to_thread(
match = await run_db_thread(
match_house_readonly,
db,
address=(dadata.canonical_address if dadata else None) or geo.full_address,
@ -4374,7 +4557,7 @@ async def estimate_quality(
)
try:
await asyncio.to_thread(_backfill_house_fias)
await run_db_thread(_backfill_house_fias)
except Exception as exc: # pragma: no cover — defensive
logger.warning("estimate fias back-fill failed (graceful): %s", exc)
except Exception as exc: # pragma: no cover — defensive
@ -4394,7 +4577,7 @@ async def estimate_quality(
# house_metadata-кэше). payload остаётся главнее в любом случае — houses
# заполняет только то, что пользователь не указал.
if target_year is None or target_house_type is None or target_total_floors is None:
house_facts = await asyncio.to_thread(
house_facts = await run_db_thread(
_lookup_house_facts,
db,
target_house_id=target_house_id,
@ -4466,7 +4649,7 @@ async def estimate_quality(
if cohort_range is not None:
cy_min, cy_max = cohort_range
listings_tier0, _, analog_tier = await asyncio.to_thread(
listings_tier0, _, analog_tier = await run_db_thread(
_fetch_analogs,
db,
lat=geo.lat,
@ -4491,7 +4674,7 @@ async def estimate_quality(
fallback_used = False
else:
# Tier 0 пуст/мал — graceful fallback на Tier A без cohort
listings, fallback_used, analog_tier = await asyncio.to_thread(
listings, fallback_used, analog_tier = await run_db_thread(
_fetch_analogs,
db,
lat=geo.lat,
@ -4511,7 +4694,7 @@ async def estimate_quality(
area_widened = False
if len(listings) < 5:
listings_wide, _, analog_tier_wide = await asyncio.to_thread(
listings_wide, _, analog_tier_wide = await run_db_thread(
_fetch_analogs,
db,
lat=geo.lat,
@ -4534,7 +4717,7 @@ async def estimate_quality(
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
if len(listings) < 3:
listings_widearea, _, analog_tier_wa = await asyncio.to_thread(
listings_widearea, _, analog_tier_wa = await run_db_thread(
_fetch_analogs,
db,
lat=geo.lat,
@ -4589,7 +4772,7 @@ async def estimate_quality(
"""Один шаг каскада #oblast-F. Возвращает (listings, tier) только если
кандидат СТРОГО больше текущей выборки иначе релаксация не засчитана
(ничего реально не выиграла) и вызывающий её не применяет."""
candidate, _, tier = await asyncio.to_thread(
candidate, _, tier = await run_db_thread(
_fetch_analogs,
db,
lat=geo.lat,
@ -4719,7 +4902,7 @@ async def estimate_quality(
# ничего), т.е. держим гарантию «не резолвится → 66», не пусто.
target_region = regions_mod.region_for_point(geo.lat, geo.lon)
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
dkp_raw = await asyncio.to_thread(
dkp_raw = await run_db_thread(
_fetch_dkp_corridor,
db,
address=payload.address,
@ -4790,7 +4973,7 @@ async def estimate_quality(
),
)
if yandex_val is not None:
saved_hist = await asyncio.to_thread(_save_yandex_history_items, db, yandex_val)
saved_hist = await run_db_thread(_save_yandex_history_items, db, yandex_val)
logger.info(
"yandex_valuation: history items processed=%d saved=%d (ext_val house_id=%s)",
len(yandex_val.history_items),
@ -4873,7 +5056,7 @@ async def estimate_quality(
_anchor_comps_pre: list[dict]
_anchor_tier_pre: str | None
if payload.area_m2 and (payload.address or (geo is not None and geo.full_address)):
_anchor_comps_pre, _anchor_tier_pre = await asyncio.to_thread(
_anchor_comps_pre, _anchor_tier_pre = await run_db_thread(
_fetch_anchor_comps,
db,
address=payload.address or geo.full_address,
@ -4887,7 +5070,7 @@ async def estimate_quality(
_anchor_comps_pre, _anchor_tier_pre = [], None
# ── Pre-fetch: house IMV anchor ONCE (used for both blend and display) ───
imv_anchor_data = await asyncio.to_thread(
imv_anchor_data = await run_db_thread(
_fetch_house_imv_anchor,
db,
target_house_id=target_house_id,
@ -4910,7 +5093,7 @@ async def estimate_quality(
and not target_quarter_cadnum
and geo is not None
):
target_quarter_cadnum = await asyncio.to_thread(
target_quarter_cadnum = await run_db_thread(
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
)
@ -4919,7 +5102,11 @@ async def estimate_quality(
appm2: float | None,
) -> tuple[float | None, str | None]:
# #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2)
# #3512: region_code — регион ЦЕЛИ (target_region_code, резолвлен по geo выше,
# тот же код, что уже скоупит ДКП-коридор, #3051 PR-A) — НЕ дефолт 66 всегда.
return _get_asking_sold_ratio(
db, payload.rooms, payload.area_m2, anchor_ppm2=appm2, region_code=target_region_code
)
def _qi_lookup(q: str) -> tuple[float, int] | None:
return _lookup_quarter_index(
@ -4936,7 +5123,7 @@ async def estimate_quality(
)
# ── Deterministic pricing orchestration ──────────────────────────────────
pr = await asyncio.to_thread(
pr = await run_db_thread(
_price_from_inputs,
listings=listings,
area_m2=payload.area_m2,
@ -5056,18 +5243,35 @@ async def estimate_quality(
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
# исключены — больше не скёюят median вторички ~110-120 К/м².
deals = await asyncio.to_thread(
deals = await run_db_thread(
_fetch_deals,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=base_radius_m,
)
# 6. Сохраняем в trade_in_estimates
estimate_id = uuid4()
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому
# попадание в неё пишется явной строкой: число оценок за сутки —
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
if dkp_corridor is not None and dkp_corridor.advisory_only:
logger.info(
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
estimate_id,
dkp_corridor.count,
settings.estimate_corridor_clamp_min_n,
(dkp_raw or {}).get("scope", "unknown"),
target_city or "unknown",
)
now = datetime.now(tz=UTC)
expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours)
@ -5263,7 +5467,7 @@ async def estimate_quality(
db.commit()
await asyncio.to_thread(_persist_estimate_and_commit)
await run_db_thread(_persist_estimate_and_commit)
logger.info(
"estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s%s",
@ -5285,7 +5489,7 @@ async def estimate_quality(
last_scraped_at = _compute_last_scraped_at(metadata_lots)
# Месячный ₽/м² тренд целевого дома (web TREND chart) — best-effort, None если нет данных.
# #audit-3: передаём freshness_months из настроек — исключаем устаревшие items.
price_trend_raw = await asyncio.to_thread(
price_trend_raw = await run_db_thread(
_fetch_price_trend,
db,
target_house_id=target_house_id,
@ -5307,7 +5511,7 @@ async def estimate_quality(
premium_building,
premium_building_median_ppm2,
premium_building_class,
) = await asyncio.to_thread(_is_premium_building, db, target_house_id)
) = await run_db_thread(_is_premium_building, db, target_house_id)
# #audit-2: структурный analog_tier — стабильный enum для фронта.
# anchor-путь: anchor_tier "A" → "same_building", "C" → "micro_radius".
@ -5860,12 +6064,34 @@ def _extract_short_addr(full_address: str | None) -> str | None:
# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
# покрывает оба варианта (с точкой и без).
_STREET_KW_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])"
# Общий список street-keyword альтернатив — единственное место, где он
# перечислен. И _STREET_KW_RE (forward, требует \s+ после), и
# _STREET_KW_SUFFIX_RE (moscow-reverse, без \s+) собираются из этой же
# константы, чтобы при добавлении нового типа улицы (аллея/линия/...) их
# списки не разъехались независимой правкой одного из двух regex.
_STREET_KW_ALTERNATION = (
r"(?:ул\.?|улица|пр\.?|пр-т|проспект|пер\.?|переулок|"
r"б-р|бульвар|ш\.?|шоссе|наб\.?|набережная|проезд|тракт|"
r"пл\.?|площадь|мкр\.?|микрорайон)"
r"\s+",
)
_STREET_KW_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])" + _STREET_KW_ALTERNATION + r"\s+",
flags=re.IGNORECASE | re.UNICODE,
)
# Тот же список keyword'ов (_STREET_KW_ALTERNATION), но БЕЗ обязательного
# \s+ после них — вместо этого lookahead на запятую/пробел/конец строки.
# Нужен для московского формата "Название улица, 6" (Тверская улица,
# Ленинский проспект, Брюсов переулок): там сразу после keyword идёт запятая,
# и _STREET_KW_RE (требующий \s+) вообще не матчит эту позицию — ни keyword,
# ни имя после него найти нельзя. Используется ТОЛЬКО как fallback ниже,
# после того как forward-путь через _STREET_KW_RE не дал результата — может
# дать результат там, где раньше был None (напр. keyword найден, но имя
# после него не распарсилось: "Тверская ул. 6"); непустые результаты
# форматов, которые уже что-то возвращали, не меняются.
_STREET_KW_SUFFIX_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])" + _STREET_KW_ALTERNATION + r"(?=[,\s]|$)",
flags=re.IGNORECASE | re.UNICODE,
)
@ -5890,10 +6116,29 @@ def extract_street_name(full_address: str | None) -> str | None:
"ул. Большая Конюшенная, 25" "Большая Конюшенная"
"" None
Московский формат keyword стоит ПОСЛЕ имени улицы ("Название улица, N"),
а не перед ним:
"Москва, Тверская улица, 6" "Тверская"
"Москва, 1-я Тверская-Ямская улица, 12" "1-я Тверская-Ямская"
"Москва, Брюсов переулок, 8" "Брюсов"
"Москва, Ленинский проспект, 30" "Ленинский"
"Театр имени М. Н. Ермоловой, 5/6, Тверская улица, 58,
Тверской район, Москва, Центральный федеральный округ,
125009, Россия" (reverse-формат Nominatim) → "Тверская"
"Москва, Проектируемый проезд № 4062, 5" None (безымянный
нумерованный проезд)
Алгоритм:
1. Ищем street-keyword (ул/улица/пр/проспект/...) case-insensitive.
2. После keyword берём 1-3 слова до запятой или номера дома.
3. Если keyword не нашёлся пытаемся первый capitalized токен с
1. Ищем street-keyword (ул/улица/пр/проспект/...) case-insensitive,
берём 1-3 слова ПОСЛЕ него до запятой или номера дома (forward-формат).
2. Московский формат: сразу после keyword запятая/конец строки (имени
после него нет, т.к. keyword в этом формате идёт ПОСЛЕ названия).
Ищем keyword ещё раз без требования пробела после него и берём 1-3
слова ДО keyword внутри той же запятой-секции ("Тверская улица, 6"
"Тверская"). Не срабатывает, если сразу после keyword ""
(нумерованный проезд без имени, напр. "Проектируемый проезд №
4062" — извлекать нечего, "Проектируемый" НЕ street name).
3. Если keyword не нашёлся вовсе пытаемся первый capitalized токен с
поиском до запятой или номера (fallback для адресов без keyword'а).
Returns None если ничего не извлеклось.
@ -5911,7 +6156,27 @@ def extract_street_name(full_address: str | None) -> str | None:
if nm:
return nm.group(1).strip()
# 2. Fallback: нет keyword — пробуем первый capitalized токен
# 2. Московский формат "Название улица, N": keyword идёт ПОСЛЕ имени, и
# сразу за ним запятая/конец строки — _STREET_KW_RE (шаг 1) такую позицию
# вообще не матчит, т.к. требует \s+ сразу после keyword. Берём keyword
# отдельно (без требования пробела) и смотрим на слова ДО него в пределах
# той же запятой-секции.
km = _STREET_KW_SUFFIX_RE.search(s)
if km:
# Исключение: "Проектируемый проезд № 4062" — keyword с числовым
# индексом БЕЗ имени (forward-паттерн "keyword № N", не moscow-
# reverse). Слово перед keyword ("Проектируемый") здесь — прилагательное
# к самому "проезду", а не имя улицы; сотни таких проездов имеют один
# и тот же "Проектируемый" — коридор по нему смешал бы все их сделки.
after = s[km.end() :].lstrip()
if not after.startswith(""):
segment_start = s.rfind(",", 0, km.start()) + 1
before = s[segment_start : km.start()].strip()
words = before.split()
if words:
return " ".join(words[-3:])
# 3. Fallback: нет keyword — пробуем первый capitalized токен
# Используется для "Большая Конюшенная, 25" без "ул."
nm = _STREET_NAME_RE.match(s)
if nm:
@ -6779,8 +7044,14 @@ def _is_plausible_deal(
def _fetch_deals(
db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int
db: Session, *, lat: float, lon: float, area: float, radius_m: int
) -> list[dict[str, Any]]:
"""#3256: параметр `rooms` УБРАН, а не просто перестал попадать в SQL.
Сделки Росреестра не несут комнатность (см. _fetch_dkp_corridor), поэтому
сравнивать её здесь не с чем оставленный «на будущее» аргумент только
приглашал бы вернуть предикат, который на самом деле фильтровал по площади.
"""
rows = (
db.execute(
text(
@ -6798,7 +7069,10 @@ def _fetch_deals(
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
FROM deals
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
AND rooms = :rooms
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
-- в комментарии _fetch_dkp_corridor.
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (:months || ' months')::interval
AND price_rub > 0
@ -6810,7 +7084,6 @@ def _fetch_deals(
"lat": lat,
"lon": lon,
"radius": radius_m,
"rooms": rooms,
"area_min": area * (1 - AREA_TOLERANCE),
"area_max": area * (1 + AREA_TOLERANCE),
"months": DEALS_PERIOD_MONTHS,

View file

@ -26,6 +26,7 @@ from sqlalchemy.orm import Session
from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings
from app.core.db import run_db_thread
from app.services import dadata
from app.services.regions import REGIONS as _ALL_REGIONS
from app.services.regions import Region, is_within_bbox
@ -1884,11 +1885,11 @@ async def suggest(
parsed = _parse_street_house(query.strip())
if parsed is not None:
street, house = parsed
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
hit = await run_db_thread(_cadastral_house_match, db, street, house)
if hit is not None:
return [hit]
# 1b. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
cad_results = await asyncio.to_thread(_cadastral_forward_sync, db, query.strip(), limit)
cad_results = await run_db_thread(_cadastral_forward_sync, db, query.strip(), limit)
if cad_results:
return cad_results
@ -1995,7 +1996,7 @@ async def _geocode_resolve(
addr_norm = _cache_key(normalize_address(address), city_hint)
# 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop)
cached = await asyncio.to_thread(_cache_get, db, addr_norm)
cached = await run_db_thread(_cache_get, db, addr_norm)
if cached is not None:
logger.info("geocode cache hit: %s", addr_norm)
return replace(cached, city_ambiguous=city_ambiguous)
@ -2028,7 +2029,7 @@ async def _geocode_resolve(
if use_local_ekb and parsed is not None:
street, house = parsed
try:
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
hit = await run_db_thread(_geoportal_house_match, db, street, house)
except Exception:
logger.warning("geoportal house-match raised — fall through", exc_info=True)
hit = None
@ -2041,7 +2042,7 @@ async def _geocode_resolve(
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
await run_db_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode geoportal house-match: %s → (%.5f, %.5f)",
addr_norm,
@ -2056,7 +2057,7 @@ async def _geocode_resolve(
# (литеральная подстрока не совпадает).
if use_local_ekb and parsed is not None:
street, house = parsed
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
hit = await run_db_thread(_cadastral_house_match, db, street, house)
if hit is not None:
result = GeocodeResult(
lat=hit.lat,
@ -2066,7 +2067,7 @@ async def _geocode_resolve(
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
await run_db_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode cadastral house-match: %s → (%.5f, %.5f)",
addr_norm,
@ -2077,9 +2078,7 @@ async def _geocode_resolve(
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
if use_local_ekb:
cad_suggestions = await asyncio.to_thread(
_cadastral_forward_sync, db, address.strip(), limit=1
)
cad_suggestions = await run_db_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
if cad_suggestions:
s = cad_suggestions[0]
result = GeocodeResult(
@ -2090,7 +2089,7 @@ async def _geocode_resolve(
confidence="exact",
city_ambiguous=city_ambiguous,
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
await run_db_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
)
@ -2101,7 +2100,7 @@ async def _geocode_resolve(
result = await _nominatim_lookup(address, city_hint, region_code)
if result is not None:
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
await run_db_thread(_cache_put, db, addr_norm, result)
logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result
except Exception:
@ -2120,7 +2119,7 @@ async def _geocode_resolve(
if use_local_ekb and parsed is not None:
local_street, _parsed_house = parsed
local_house = _extract_local_house_token(address) or _parsed_house
hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house)
hit = await run_db_thread(_local_houses_match, db, local_street, local_house)
if hit is not None:
result = GeocodeResult(
lat=hit.lat,
@ -2329,7 +2328,7 @@ async def reverse_geocode(
"""
# 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
if db is not None:
cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon)
cad = await run_db_thread(_cadastral_reverse_sync_full, db, lat, lon)
if cad is not None:
address, snap_lat, snap_lon = cad
return ReverseGeocodeResult(

View file

@ -142,7 +142,8 @@ class LocationIndexResult(BaseModel):
sample_size: int
radius_m: int
nearby_poi: list[NearbyPoi]
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена
# ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице)
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
@ -207,9 +208,27 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
#
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
# явно ограничил владелец продукта.
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона,
# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию
# location_index явно ограничил владелец продукта.
#
# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический
# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 /
# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
# 458 208) — примесь заниженная городскую медиану до 422 222 (7.9%). У областных строк
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
#
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
_MEDIAN_PPM2_LOCAL_SQL = text(
"""
SELECT
@ -223,6 +242,7 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -248,6 +268,7 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -255,6 +276,17 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
"""
)
_POI_COUNT_IN_REGION_SQL = text(
"""
SELECT count(*) AS n
FROM osm_poi_ekb_local
WHERE lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
AND CAST(:bbox_east AS double precision)
"""
)
_NEAREST_POI_SQL = text(
"""
SELECT
@ -293,6 +325,7 @@ def _local_median_ppm2(
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
@ -317,6 +350,7 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
@ -333,18 +367,38 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
def _fetch_nearby_poi(
db: Any, lat: float, lon: float, radius_m: int, top_n: int
db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region
) -> tuple[list[NearbyPoi], str]:
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
([], "unavailable") вместо 500 или сфабрикованного списка.
Graceful fallback ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект
(прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850
строк, все екатеринбургские) для Москвы это count > 0 (таблица не пуста), и статус
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
региона точки), а не на размер таблицы целиком. Загрузка московских POI отдельная
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
не расширяется trade_in.py и так трактует его как «нет данных», см. #2131).
"""
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
if total == 0:
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
in_region = (
db.execute(
_POI_COUNT_IN_REGION_SQL,
{
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
).scalar()
or 0
)
if in_region == 0:
logger.warning(
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
"environment) — nearby_poi unavailable, no fabricated factors"
"location_index: osm_poi_ekb_local has no rows in region=%s product core "
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
"no fabricated factors",
region.code,
)
return [], "unavailable"
@ -423,7 +477,7 @@ def compute_location_index(
poi_status="unavailable",
)
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region)
city_median, city_n = _citywide_median_ppm2(db, region)
if city_median is None or city_n < MIN_SAMPLE_SIZE:

View file

@ -39,6 +39,9 @@ from scraper_kit.orchestration.scheduler import (
from scraper_kit.orchestration.scheduler import (
_pick_resume as kit_pick_resume,
)
from scraper_kit.orchestration.scheduler import (
_resolve_region_code as kit_resolve_region_code,
)
if TYPE_CHECKING:
from scraper_kit.orchestration.scheduler import SchedulerContext
@ -325,6 +328,34 @@ async def _job_landing_stats(
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
async def _job_landing_showcase_deals(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Пересчёт витрины сделок публичного лэндинга (#3469).
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
`run_id` ничего не знает тот же случай, что у `_job_refresh_search_matview`,
и решается так же: done/failed ставим здесь.
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
"""
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
)
ctx.runs.mark_done(db, run_id, counters)
except Exception:
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -487,6 +518,7 @@ async def _job_domclick_city_sweep(
rooms=params.get("rooms"),
pages=int(params.get("pages_per_anchor", 5)),
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
region_code=kit_resolve_region_code(params),
resume_run_id=kit_pick_resume(db, run_id),
cookies=cookies,
)
@ -911,6 +943,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"

View file

@ -302,7 +302,7 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
# Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного
# здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env.
logger.error(
logger.warning(
"proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН "
"не прошёл фильтр для этого источника (banned_for_source=%d, "
"unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через "

View file

@ -1309,6 +1309,8 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
транзиентный сбой узла перманентный бан, используется пока только для логов):
- "timeout" сеть недоступна/медленная (httpx.TimeoutException)
- "connect_error" прокси не поднят/не слушает/DNS (httpx.ConnectError)
- "proxy_error" сам прокси отверг соединение (httpx.ProxyError, напр. 407 от
провайдера это состояние пула, а не инцидент; #3471)
- "http_error" ipify ответил ошибкой через прокси (auth/upstream)
- "other" прочее
@ -1328,6 +1330,14 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
except httpx.ConnectError:
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
return False, None, None, "connect_error"
except httpx.ProxyError as exc:
# #3471: сам прокси-провайдер отверг соединение (чаще всего 407 —
# исчерпан лимит/просрочен пакет) — штатный исход health-пробы, не
# инцидент приложения. Одна строка без трейса: узел + причина текстом
# исключения, полный traceback здесь не несёт новой информации и только
# засорял логи (184 строки/сутки, #3471).
logger.warning("proxy_pool: health probe proxy_error proxy=%s reason=%s", _mask(url), exc)
return False, None, None, "proxy_error"
except httpx.HTTPStatusError as exc:
logger.warning(
"proxy_pool: health probe http_error proxy=%s status=%s",

View file

@ -28,9 +28,18 @@ class Region:
"""Один регион покрытия продукта.
bbox_tight ядро города: geocoder-фильтрация фуззи-матчей провайдеров
(не принять соседний город за совпадение по опечатке).
(не принять соседний город за совпадение по опечатке). У
региона БЕЗ одного центрального города (50 область, много
сопоставимых по объёму городов, ни один не «ядро») равен
bbox_product_core: эмпирический пояс, где данные РЕАЛЬНО
наблюдались (перцентили 0.5..99.5 координат сырья), а не
административная граница см. REGIONS[50] и обоснование там.
bbox_wide город + легитимное приграничье: ingest-guard координат,
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
У 50 полный наблюдённый диапазон координат (min..max, без
перцентильной обрезки) вместо «город + отступ»: без своего
города отступать не от чего, поэтому граница «легитимности»
здесь тоже эмпирическая, просто менее обрезанная, чем tight.
bbox_region генеральный bbox региона: fallback-accept для провайдеров без
структурного region-поля. Содержит wide.
bbox_product_core гео-охват ПРОДУКТА в этом регионе: location_index
@ -38,9 +47,14 @@ class Region:
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
location_index (56.70..56.95/60.50..60.75), синхронизирован с
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
(комментарий в обе стороны, см. site_finder/poi_loader.py).
(комментарий в обе стороны, см. site_finder/poi_loader.py). У
50 намеренно НЕ административный bbox (обещать охват там,
где нет ни одного объявления, нельзя) эмпирический пояс
фактических данных, см. REGIONS[50].
city_token нормализованный токен главного города (нижний регистр, е==ё
нормализует потребитель matching.normalize).
нормализует потребитель matching.normalize). У региона без
единого центра (50) самый объёмный по данным город, который
ОДНОВРЕМЕННО де-факто административный: см. REGIONS[50].
cities узнаваемые города региона (для city_hint / prefix-логики
геокодера). НЕ исчерпывающий список основные центры.
enrichment_tiers какие тиры обогащения РЕАЛЬНО доступны региону.
@ -159,19 +173,122 @@ REGIONS: dict[int, Region] = {
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
canonical_city="Москва",
),
50: Region(
code=50,
name="Московская область",
# У области НЕТ города-ядра (в отличие от 66/77) — 20 сопоставимых по
# объёму городов-спутников. Поэтому tight/wide/product_core здесь не
# «город + отступ», а ЭМПИРИЧЕСКИЙ пояс данных: разброс координат
# подмосковного сырья Циан (45 294 строки, отбор по городскому
# поддомену ссылки ≠ www, замер на дату добавления региона):
# полный диапазон: lat 54.673..56.762, lon 35.920..39.888
# перцентили 0.5..99.5: lat 54.834..56.728, lon 36.193..39.545
# tight = product_core = перцентильный пояс (без выбросов из хвоста
# распределения — то немногое, что уверенно наблюдали). wide = полный
# диапазон (min..max) — легитимное приграничье для ingest-guard шире
# tight, но всё ещё эмпирическое, не административное.
bbox_tight=(54.834, 56.728, 36.193, 39.545),
bbox_wide=(54.673, 56.762, 35.920, 39.888),
# region — административный bbox МО целиком (fallback-accept должен
# покрывать всю область, а не только пояс, где уже есть данные):
# lat 54.20..56.96, lon 35.14..40.21.
bbox_region=(54.20, 56.96, 35.14, 40.21),
# product_core НЕ равен bbox_region: location_index не должен обещать
# медианы там, где по факту нет ни одного объявления (deals=0,
# listings=0 на дату добавления — импорт 411 056 сделок Росреестра из
# FDW идёт отдельным PR). Равен tight — см. выше.
bbox_product_core=(54.834, 56.728, 36.193, 39.545),
# Красногорск: и самый объёмный город по факту сырья (см. cities ниже,
# по убыванию объёма), и де-факто административный центр региона —
# Правительство Московской области физически размещается в Красногорске
# с 2013 г. (Москва как формальный административный центр — экстра-
# территориальна и уже занята регионом 77). Единственный кандидат,
# обоснованный ОБОИМИ критериями сразу.
city_token="красногорск",
cities=frozenset(
{
"красногорск",
"балашиха",
"видное",
"люберцы",
"звенигород",
"химки",
"мытищи",
"подольск",
"одинцово",
"солнечногорск",
"домодедово",
"королёв",
"королев",
"котельники",
"дмитров",
"электросталь",
"реутов",
"щёлково",
"щелково",
"серпухов",
"ногинск",
"железнодорожный",
}
),
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
# за пределами этого реестра не спрашивают).
enrichment_tiers=frozenset(),
# Источники по области несут настоящий city (Химки, Балашиха — не
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
# нечего и незачем, в отличие от 77.
canonical_city=None,
),
}
DEFAULT_REGION_CODE = 66
def _bbox_area(bbox: BBox) -> float:
"""Грубая «площадь» bbox в кв. градусах (lat_range * lon_range).
Не учитывает сжатие долготы на широте (cos(lat)) не нужно: значение
используется ТОЛЬКО чтобы сравнить специфичность bbox'ов разного порядка
(город vs область), не как настоящая площадь в км²."""
lat_min, lat_max, lon_min, lon_max = bbox
return (lat_max - lat_min) * (lon_max - lon_min)
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
#
# Почему код региона как ключ порядка сломался: bbox_region(50) (Московская
# область целиком, lat 54.20..56.96/lon 35.14..40.21) геометрически СОДЕРЖИТ
# bbox_region(77) (Москва, 55.10..56.10/36.80..38.10) как прямоугольники — а
# 50 < 66 < 77 по числу. При обходе `sorted(REGIONS)` регион 50 проверялся бы
# ПЕРВЫМ (50 < 77) и забирал бы себе ВСЕ точки Москвы, включая центр
# (55.75, 37.62) — она лежит в bbox_region обоих регионов одновременно. Старый
# докстринг называл это «на случай, если когда-нибудь пересекутся» — случай
# наступил прямо при добавлении региона 50, не гипотетически.
#
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает это БЕЗ
# ручного списка: чем компактнее регион, тем раньше его проверяют, поэтому
# вложенный регион (77 внутри 50) всегда выигрывает у объемлющего, а будущий
# новый регион сам встанет в верную позицию по своей площади — правку этого
# места повторять не придётся.
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
)
def region_for_point(lat: float, lon: float) -> Region | None:
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
Регионы географически не пересекаются; порядок обхода детерминирован кодом
региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает
и это станет видно в тестах реестра, а не в проде).
Обход `_POINT_LOOKUP_ORDER` (компактный bbox_region раньше обширного), а
не числовой код региона: код как ключ порядка ломается ровно на паре
50/77, см. комментарий у `_POINT_LOOKUP_ORDER`.
"""
for code in sorted(REGIONS):
for code in _POINT_LOOKUP_ORDER:
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
return REGIONS[code]
return None
@ -179,7 +296,16 @@ def region_for_point(lat: float, lon: float) -> Region | None:
def region_by_city(city: str | None) -> Region | None:
"""Регион, в чьём списке городов есть `city` (нормализованный нижний
регистр, е/ё не различаются). None город не узнан ни одним регионом."""
регистр, е/ё не различаются). None город не узнан ни одним регионом.
В отличие от `region_for_point`, здесь нет геометрической вложенности
сравнение точное (токен строки), не bbox-containment, поэтому порядок по
коду региона не создаёт баг ordering'а САМ ПО СЕБЕ. Он МОГ бы сломаться,
если бы одно имя города оказалось в `cities` двух регионов (тогда побеждал
бы меньший код) список городов 50 сверен вручную с `cities` регионов 66
и 77, пересечений нет (закреплено test_no_city_name_duplicated_across_regions
в tests/test_3051_region_registry_moscow_oblast.py).
"""
if not city:
return None
token = " ".join(city.lower().replace("ё", "е").split())

View file

@ -40,6 +40,18 @@ REF_AREA codes (sberindex internal region IDs, not ОКАТО/ISO):
77 = Москва verified 2026-05-31 brute-force against /api/sowa
(region name from ref_area field of response);
real_estate_deals: 2017-01 148 471 2026-04 309 510 руб/м².
50 = Московская область verified live 2026-09-12 (все три дашборда, ref_area
в ответе = «Московская область»); real_estate_deals 116 месячных точек
2017-01..2026-08, residential_real_estate_prices 47 (2022-10..2026-08),
dinamika-tsen-obyavlenii 56 (2021-12..2026-07) та же глубина и свежесть,
что у Москвы.
Область заведена ЗАРАНЕЕ, до появления региона 50 в реестре regions.REGIONS. Ряд
источника наполняется месяцами и задним числом не восстанавливается, поэтому
загружать его надо начинать раньше, чем он понадобится оценщику. На саму оценку
это не влияет: estimator берёт ряды через _SBER_REGION_SERIES, кода 50 там нет, и
до его появления область в расчёт не попадает. Мониторинг свежести тоже не
затронут SBER_REQUIRED_REGIONS считается от карты ЭСТИМАТОРА, а не отсюда.
"""
from __future__ import annotations
@ -71,6 +83,7 @@ SBER_REF_AREAS: dict[str, str] = {
"643": "Россия",
"66": "Свердловская область",
"77": "Москва",
"50": "Московская область",
}

View file

@ -29,9 +29,13 @@
Telegram 403 (клиент заблокировал бота) is_blocked=true + уведомление в
топике (только для Telegram-ветки у веб-клиента нет "заблокировал бота").
C) Дедуп: update_id <= сохранённого offset skip. Offset сохраняется И
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
`finally`), после КАЖДОГО апдейта рестарт воркера не переигрывает уже
обработанные апдейты и не подвисает вечно на «ядовитом» апдейте.
коммитится в той же транзакции, что и запись сообщения (см. `process_update`),
после КАЖДОГО апдейта рестарт воркера не переигрывает уже обработанные
апдейты и не подвисает вечно на «ядовитом» апдейте. Исключение
ТРАНЗИЕНТНЫЙ сетевой отказ (`TelegramNetworkError`, т.е. исчерпанный бюджет
ретраев клиента): такой апдейт СОЗНАТЕЛЬНО остаётся неподтверждённым, чтобы
Telegram отдал его снова, иначе ответ оператора пропадал бы навсегда
(#tg-connection-resilience). Потолок переигрываний — `_MAX_NETWORK_REPLAYS`.
D) TELEGRAM_BOT_TOKEN пуст бот выключен проверяется в `app.tgbot_main`
(entrypoint), не здесь.
E) /start клиенту короткое приветствие МЕРЫ, без зеркалирования в топик
@ -76,7 +80,7 @@ from app.core.config import settings
from app.core.ratelimit import SlidingWindowLimiter
from app.core.shutdown import shutdown_requested
from app.services.tgbot import web_support_storage
from app.services.tgbot.client import TelegramApiError, TelegramClient
from app.services.tgbot.client import TelegramApiError, TelegramClient, TelegramNetworkError
logger = logging.getLogger(__name__)
@ -131,6 +135,59 @@ FLOOD_LIMITED_TEXT = (
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
# Уведомления оператору в топике отправляются ИЗНУТРИ poll loop, который
# однопоточный: пока висит одна отправка, не обрабатывается НИ ОДИН следующий
# апдейт. Поэтому им нужен интерактивный бюджет, а не воркерный дефолт клиента
# (5 ретраев, backoff до 30с, полный retry_after на 429 — минуты стопа на
# ВТОРИЧНОМ действии). Числа — те же, что у интерактивных отправок веб-чата
# поддержки (`_INTERACTIVE_SEND_*` в app/api/v1/support.py, подобраны замером
# прода #tgsupport-retry); сознательно ДУБЛИРУЕМ, а не импортируем из слоя API —
# воркер не должен зависеть от роутера.
_NOTIFY_SEND_TIMEOUT_S = 5.0
_NOTIFY_SEND_MAX_RETRIES = 3
_NOTIFY_SEND_MAX_BACKOFF_S = 1.0
# Потолок ожидания в TelegramGroupRateLimiter для отправок ИЗ ЭТОГО модуля,
# которые не проходят через _notify_topic (review M1, #3471). Без него
# `client.send_message`/`copy_message` без явного `timeout` наследуют
# лимитер-политику "без потолка" (см. TelegramClient._request) — приемлемую
# ДЛЯ ФОНОВОЙ отправки как таковой, но НЕ здесь: эти вызовы идут внутри
# `run_poll_loop`, который на каждый апдейт держит ОТКРЫТУЮ сессию БД
# (SessionLocal, см. вызывающих) и однопоточно блокирует опрос СЛЕДУЮЩИХ
# апдейтов — минутный сон здесь стопорит и БД-соединение, и весь мост, а не
# только одно сообщение. Второй повод — SIGTERM drain: `tgbot_main._DRAIN_TIMEOUT_S`
# даёт 100с на завершение текущей итерации; ожидание слота дольше этого
# бюджета уже не успевает подчиниться cooperative drain. 20с — заметно больше,
# чем разумная очередь при исчерпанном лимите (окно 60с, обычно секунды), но
# заметно МЕНЬШЕ минуты и вписывается в drain-бюджет с запасом.
_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S = 20.0
# Потолок переигрываний ОДНОГО update_id на транзиентных сетевых отказах
# (#tg-connection-resilience).
#
# Зачем потолок: без него «вечно недоставляемый» апдейт заклинил бы очередь
# НАВСЕГДА — ровно то, от чего защищал прежний безусловный `finally: save_offset`.
# Потерять одно сообщение плохо, потерять весь поток — хуже, поэтому на потолке
# offset всё-таки сдвигается, но ГРОМКО (`logger.error`), а не молча.
#
# Про дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев клиента,
# при этом запрос МОГ дойти до Telegram (потерялся ответ) — переигрывание тогда
# доставит клиенту то же сообщение второй раз. Это осознанный at-least-once
# компромисс: дубль и клиент, и оператор ВИДЯТ и могут поправить, а тихая потеря
# ответа не оставляет следа нигде, кроме строчки в логе. Полноценная
# идемпотентность по (update_id, target_chat_id) потребовала бы нового
# персистентного состояния (колонка/таблица + миграция) ради редкого случая;
# вместо этого число возможных дублей жёстко ограничено сверху — не больше
# (_MAX_NETWORK_REPLAYS - 1) повторов на апдейт.
_MAX_NETWORK_REPLAYS = 3
# update_id -> сколько раз мы уже отказались подтверждать этот апдейт.
# In-memory осознанно: воркер long-polling однопоточный, запись живёт ровно до
# подтверждения апдейта (`pop` в `process_update`), так что словарь не растёт.
# Рестарт воркера обнуляет счётчик — это допустимо (новый процесс = новая сеть),
# потолок всё равно действует в пределах каждой жизни процесса.
_network_replay_attempts: dict[int, int] = {}
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
class BridgeStorage(Protocol):
@ -181,7 +238,13 @@ class BridgeStorage(Protocol):
) -> int | None: ...
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
self,
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
) -> None: ...
@ -334,14 +397,20 @@ class SqlBridgeStorage:
со ЧУЖИМ (не NULL, не текущим) support_chat_id исторический артефакт
ротации support-группы, не валидный маршрут сегодня. NULL (строки до
миграции 188, если есть) лениентный wildcard-матч (единственный
действовавший чат на тот момент)."""
действовавший чат на тот момент).
БЕЗ фильтра по direction (#3471 P0, было `AND direction = 'in'`): с тех
пор как `record_message` на исходящем ответе тоже сохраняет
`topic_message_id` (id сообщения оператора В ТОПИКЕ), реплай оператора
на СВОЙ предыдущий ответ обязан резолвиться так же, как реплай на
зеркало клиента иначе продолжение диалога без повторного цитирования
клиента тихо проваливалось в orphan-check."""
row = self._db.execute(
text(
"""
SELECT chat_id
FROM tg_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint)
OR support_chat_id IS NULL)
ORDER BY created_at DESC
@ -371,13 +440,21 @@ class SqlBridgeStorage:
)
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
self,
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
) -> None:
web_support_storage.record_outbound(
self._db,
thread_id=thread_id,
text_body=text_body,
operator_tg_id=operator_tg_id,
topic_message_id=topic_message_id,
support_chat_id=support_chat_id,
)
@ -400,6 +477,51 @@ def _format_topic_header(
return f"Новое обращение от {display_name}{username_part} (chat_id={chat_id})"
async def _notify_topic(
client: TelegramClient,
*,
text: str,
reply_to_message_id: int | None,
context: str,
) -> bool:
"""Служебное уведомление оператору в support-топик (вторичное действие).
Два свойства, которых не было у прямых `client.send_message` вызовов:
1) узкий интерактивный бюджет (`_NOTIFY_SEND_*`) иначе одна такая отправка
стопорит весь однопоточный poll loop на минуты;
2) собственный `except` провал УВЕДОМЛЕНИЯ не отменяет основную ветку
обработки (клиент уже помечен заблокированным / медиа-реплай уже отклонён)
и не решает судьбу апдейта.
`context` только технические идентификаторы (chat_id/thread_id), НЕ текст
переписки: логи моста принципиально не содержат ПДн.
Возвращает True, если уведомление реально ушло, False если само
уведомление тоже упало (напр. Telegram недоступен). Вызывающий, для
которого проваленное уведомление означает ПОЛНУЮ тишину (ни клиенту, ни
оператору), обязан на False залогировать `logger.error` с идентификаторами
(#3471 P0) — иначе единственный след остаётся только в этом WARNING.
"""
try:
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=text,
message_thread_id=settings.telegram_support_topic_id or None,
reply_to_message_id=reply_to_message_id,
timeout=_NOTIFY_SEND_TIMEOUT_S,
max_retries=_NOTIFY_SEND_MAX_RETRIES,
max_backoff=_NOTIFY_SEND_MAX_BACKOFF_S,
)
return True
except Exception:
logger.warning(
"tgbot bridge: не удалось отправить уведомление оператору в топик (%s) — "
"основная ветка обработки не отменяется",
context,
exc_info=True,
)
return False
# ── Update routing ────────────────────────────────────────────────────────────
async def _handle_private_message(
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
@ -428,7 +550,11 @@ async def _handle_private_message(
text_body = message.get("text")
if text_body == "/start":
# E) команда — не содержательное обращение, топик не засоряем.
await client.send_message(chat_id=chat_id, text=GREETING_TEXT)
await client.send_message(
chat_id=chat_id,
text=GREETING_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return
if not settings.telegram_support_chat_id:
@ -438,7 +564,11 @@ async def _handle_private_message(
chat_id,
)
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
await client.send_message(
chat_id=chat_id,
text=SERVICE_UNAVAILABLE_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return
message_id = message.get("message_id")
@ -466,7 +596,11 @@ async def _handle_private_message(
# за окно — `_flood_notify_limiter.check()` возвращает None (и сам
# фиксирует попытку) ровно один раз за окно.
if _flood_notify_limiter.check(flood_key) is None:
await client.send_message(chat_id=chat_id, text=FLOOD_LIMITED_TEXT)
await client.send_message(
chat_id=chat_id,
text=FLOOD_LIMITED_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return
_flood_limiter.record(flood_key)
@ -477,6 +611,7 @@ async def _handle_private_message(
chat_id=settings.telegram_support_chat_id,
text=header,
message_thread_id=settings.telegram_support_topic_id or None,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
mirrored = await client.copy_message(
@ -484,6 +619,7 @@ async def _handle_private_message(
from_chat_id=chat_id,
message_id=message_id,
message_thread_id=settings.telegram_support_topic_id or None,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
@ -548,19 +684,20 @@ async def _handle_group_reply(
chat_id=target_chat_id,
from_chat_id=settings.telegram_support_chat_id,
message_id=message_id,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
except TelegramApiError as exc:
if exc.error_code == 403:
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике.
storage.mark_blocked(target_chat_id)
await client.send_message(
chat_id=settings.telegram_support_chat_id,
await _notify_topic(
client,
text=(
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
"бот заблокирован."
),
message_thread_id=settings.telegram_support_topic_id or None,
reply_to_message_id=message_id,
context=f"403 на доставке клиенту chat_id={target_chat_id}",
)
return
raise
@ -570,10 +707,22 @@ async def _handle_group_reply(
chat_id=target_chat_id,
direction="out",
tg_message_id=tg_message_id,
topic_message_id=None,
# #3471 P0: id ЭТОГО сообщения оператора В ТОПИКЕ (было безусловно
# None) — без него реплай оператора на СВОЙ предыдущий ответ не
# резолвился (искать было нечего), маршрут держался только на
# зеркале клиента. Совпадение с `in`-записью структурно исключено:
# `message_id` — id реплая оператора, а зеркало клиента уже занимает
# другой message_id в том же чате.
topic_message_id=message_id,
kind=_infer_kind(message),
text_body=message.get("text") or message.get("caption"),
operator_tg_id=operator_id,
# Deep review PR #3479: без этого out-строка была бы вечным
# wildcard для `find_chat_by_topic_message` (матчит support_chat_id
# IS NULL под ЛЮБЫМ текущим чатом) — при ротации support-группы
# (188) новый message_id мог бы совпасть со старой out-строкой и
# увести ответ ЧУЖОМУ клиенту. Симметрично in-ветке выше (строка ~601).
support_chat_id=settings.telegram_support_chat_id,
)
return
@ -592,11 +741,11 @@ async def _handle_group_reply(
web_thread_id,
kind,
)
await client.send_message(
chat_id=settings.telegram_support_chat_id,
await _notify_topic(
client,
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
message_thread_id=settings.telegram_support_topic_id or None,
reply_to_message_id=message_id if isinstance(message_id, int) else None,
context=f"медиа-реплай на веб-зеркало thread_id={web_thread_id}",
)
return
@ -607,11 +756,64 @@ async def _handle_group_reply(
operator = message.get("from") or {}
operator_id = operator.get("id")
storage.record_web_out_message(
thread_id=web_thread_id,
text_body=text_body,
operator_tg_id=operator_id,
)
try:
storage.record_web_out_message(
thread_id=web_thread_id,
text_body=text_body,
operator_tg_id=operator_id,
# #3471 P0: id ЭТОГО сообщения оператора в топике — без него
# реплай оператора на СВОЙ предыдущий веб-ответ не резолвится
# (см. `find_thread_by_topic_message`, direction-фильтр снят).
topic_message_id=message_id if isinstance(message_id, int) else None,
# Deep review PR #3479: БЕЗ этого out-строка писалась бы с
# support_chat_id=NULL — `find_thread_by_topic_message` матчит
# NULL под ЛЮБЫМ текущим чатом (лениентный wildcard для легаси
# строк до 187/188), т.е. каждая out-строка стала бы вечным
# wildcard. При ротации support-группы новый message_id мог бы
# совпасть со старой out-строкой и увести ответ в ЧУЖОЙ тред —
# ровно то, от чего защищала скоупинг-миграция 187/188.
support_chat_id=settings.telegram_support_chat_id,
)
except SQLAlchemyError:
# #3471 P0: для веб-треда ЭТА запись — и есть доставка клиенту (веб-
# фронт вычитывает ответ обычным polling'ом web_support_messages).
# Откат без уведомления означал бы: оператор уверен, что ответил,
# клиент ждёт молча. Offset ниже всё равно сдвигается — НЕ потому,
# что апдейт "частично применён в Telegram" (в этой ветке до сбоя в
# Telegram ничего не уходило вообще: сам реплай оператора Telegram
# уже полностью доставил ДО того, как мы начали его разбирать,
# ретраить на стороне площадки нечего), а потому что действует общая
# политика `process_update` для `SQLAlchemyError` — сбой БД не
# переигрывается (в отличие от `TelegramNetworkError`), а
# сигнализируется громко; здесь это explicit-просьба оператору
# прислать ответ заново — human-in-the-loop retry вместо
# технического. rollback() ОБЯЗАН отработать ДО уведомления —
# сессия в failed-transaction state, а `_notify_topic` шлёт через
# `client`, не через `storage`, поэтому сам rollback тут не нужен для
# отправки, но нужен, чтобы process_update дальше не упал на
# save_offset/commit тем же PendingRollbackError (см. #3 review).
storage.rollback()
notified = await _notify_topic(
client,
text=(
"Не удалось сохранить ваш ответ из-за сбоя базы данных — клиенту "
"он НЕ доставлен. Пожалуйста, отправьте ответ ещё раз."
),
reply_to_message_id=message_id if isinstance(message_id, int) else None,
context=f"сбой БД на доставке веб-ответа thread_id={web_thread_id}",
)
if not notified:
# Оба канала молчат (БД и уведомление) — единственный след,
# который останется, это эта строка. Идентификаторы, НЕ текст
# (ПДн в лог не идёт) — по ним человек найдёт ответ оператора в
# топике и перешлёт его руками (#3471 P0).
logger.error(
"tgbot bridge: сбой БД на веб-ответе И не удалось уведомить "
"оператора (thread_id=%d, message_id=%s) — ответ клиенту "
"потерян молча, требуется ручной разбор support-топика",
web_thread_id,
message_id,
)
return
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
@ -636,25 +838,46 @@ async def _handle_group_reply(
async def process_update(
update: dict[str, Any], client: TelegramClient, storage: BridgeStorage
) -> None:
) -> bool:
"""Маршрутизирует один Telegram update. Дедуп (C) + атомарный offset-commit.
Дедуп: update_id <= сохранённого offset skip без side-effects. Offset
сохраняется и коммитится ПОСЛЕ обработки (в т.ч. если обработка упала
иначе «ядовитый» апдейт блокировал бы весь поток навсегда).
Возвращает True, если offset сдвинут (апдейт подтверждён, Telegram его больше
не отдаст), и False, если апдейт СОЗНАТЕЛЬНО оставлен неподтверждённым ради
переигрывания. На False вызывающий (`run_poll_loop`) ОБЯЗАН прервать разбор
пачки: offset у Telegram единая «высшая отметка», подтверждение любого
СЛЕДУЮЩЕГО апдейта неявно подтвердило бы и этот, и переигрывания не было бы.
Различаем сбой БД (`SQLAlchemyError`) от прочих (Telegram API и т.п.):
сбой БД оставляет сессию в failed-transaction state `rollback()` ОБЯЗАН
отработать ПЕРЕД `save_offset`, иначе тот сам кинет `PendingRollbackError`,
`process_update` вылетит без сохранения offset'а, следующая итерация
`run_poll_loop` получит СТАРЫЙ offset от `get_offset()` и переиграет тот же
апдейт заново copyMessage задублирует зеркало клиента в топике на
каждый повтор поллинга (#3 review, воспроизведено).
Дедуп: update_id <= сохранённого offset skip без side-effects.
Судьба offset'а по классам отказа:
- `TelegramNetworkError` (транзиентный: Telegram не ответил, бюджет ретраев
клиента исчерпан) offset НЕ двигаем, `rollback()` частичных записей,
апдейт переигрывается на следующей итерации. Иначе ответ оператора
терялся НАВСЕГДА: copyMessage не дошёл, `record_message` не выполнился,
Telegram апдейт больше не отдаст, а оператор уверен, что ответил
(#tg-connection-resilience). Ограничено `_MAX_NETWORK_REPLAYS` — на
потолке offset всё-таки сдвигается с `logger.error`, иначе «вечно
недоставляемый» апдейт заклинил бы поток навсегда.
- `SQLAlchemyError` (сбой БД) offset двигаем, но `rollback()` ОБЯЗАН
отработать ПЕРЕД `save_offset`: сбой БД оставляет сессию в
failed-transaction state, иначе `save_offset` сам кинет
`PendingRollbackError`, `process_update` вылетит без сохранения offset'а,
следующая итерация получит СТАРЫЙ offset от `get_offset()` и переиграет
тот же апдейт copyMessage задублирует зеркало клиента в топике на
каждый повтор поллинга (#3 review, воспроизведено). Для веб-ветки
(`_handle_group_reply` `record_web_out_message`) этот `SQLAlchemyError`
перехватывается ЛОКАЛЬНО, до этого места: там запись в БД И ЕСТЬ
доставка клиенту, поэтому rollback сопровождается уведомлением оператору
в топике, что ответ НЕ доставлен (#3471 P0) — сюда, на верхний уровень,
это исключение уже не долетает.
- любое прочее исключение (в т.ч. `TelegramApiError` площадка ОТВЕТИЛА
отказом, повтор ничего не изменит) offset двигаем, «ядовитый» апдейт
не блокирует поток.
"""
update_id = update.get("update_id")
if not isinstance(update_id, int):
logger.warning("tgbot bridge: update без валидного update_id — игнор")
return
return True
current_offset = storage.get_offset()
if update_id <= current_offset:
@ -663,7 +886,7 @@ async def process_update(
update_id,
current_offset,
)
return
return True
message = update.get("message")
try:
@ -677,6 +900,35 @@ async def process_update(
await _handle_group_reply(message, client, storage)
# иначе — необрабатываемый тип чата/апдейта (edited_message, канал и
# т.п.) — тихий игнор, но offset всё равно сдвигаем ниже.
except TelegramNetworkError:
attempts = _network_replay_attempts.get(update_id, 0) + 1
# Частичные записи этого апдейта не должны уехать в БД чужим commit'ом
# (сессия одна на всю пачку) — переигрывание начинается с чистого листа.
storage.rollback()
if attempts < _MAX_NETWORK_REPLAYS:
_network_replay_attempts[update_id] = attempts
logger.warning(
"tgbot bridge: Telegram недоступен на update_id=%d (отказ %d из %d) — "
"offset НЕ сдвигаем, апдейт переиграется на следующей итерации",
update_id,
attempts,
_MAX_NETWORK_REPLAYS,
)
return False
logger.error(
"tgbot bridge: update_id=%d исчерпал потолок переигрываний (%d сетевых "
"отказов подряд) — сдвигаем offset, содержимое апдейта ПОТЕРЯНО; поток не "
"блокируем, требуется ручной разбор support-топика "
"(chat_id=%s, message_id=%s)",
update_id,
_MAX_NETWORK_REPLAYS,
# Идентификаторы, а НЕ текст: это единственная строка, по которой
# человек найдёт потерянный ответ оператора в топике и перешлёт его
# руками. Без них в логе остаётся только update_id, которого в
# интерфейсе Telegram не видно. Текст сообщения — ПДн, в лог не идёт.
(message or {}).get("chat", {}).get("id") if isinstance(message, dict) else None,
(message or {}).get("message_id") if isinstance(message, dict) else None,
)
except SQLAlchemyError:
logger.exception(
"tgbot bridge: DB-ошибка на update_id=%d — rollback перед сохранением "
@ -690,9 +942,12 @@ async def process_update(
"(не блокируем поток на 'ядовитом' апдейте)",
update_id,
)
finally:
storage.save_offset(update_id)
storage.commit()
# Апдейт подтверждён — счётчик переигрываний больше не нужен (словарь не растёт).
_network_replay_attempts.pop(update_id, None)
storage.save_offset(update_id)
storage.commit()
return True
# ── Long-polling loop ─────────────────────────────────────────────────────────
@ -717,8 +972,20 @@ async def run_poll_loop(
updates = await client.get_updates(
offset=offset + 1, timeout=poll_timeout_s, allowed_updates=["message"]
)
for update in updates:
await process_update(update, client, storage)
for idx, update in enumerate(updates):
if not await process_update(update, client, storage):
# Апдейт намеренно не подтверждён (транзиентный сетевой
# отказ). Обрабатывать остаток пачки НЕЛЬЗЯ: offset —
# единая «высшая отметка», подтверждение следующего
# апдейта неявно подтвердило бы и этот. Остаток Telegram
# отдаст заново на следующей итерации.
logger.warning(
"tgbot bridge: update_id=%s не подтверждён — остаток пачки "
"(%d апдейтов) разберём на следующей итерации",
update.get("update_id"),
len(updates) - idx - 1,
)
break
consecutive_errors = 0
except Exception:
consecutive_errors += 1

View file

@ -12,11 +12,22 @@ Docs: https://core.telegram.org/bots/api
Ретраи:
- HTTP 429 (Too Many Requests) уважаем `parameters.retry_after` из тела ответа
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
- HTTP 5xx / сетевые ошибки (timeout/connect) экспоненциальный backoff,
`capped` на `_MAX_BACKOFF_S`.
- HTTP 5xx / транспортные ошибки (`httpx.TransportError`: timeout, connect,
обрыв протокола, прокси) экспоненциальный backoff, `capped` на
`_MAX_BACKOFF_S`.
- Прочие отказы запроса (`httpx.RequestError`: битый ответ) НЕ ретряются,
сразу `TelegramNetworkError`: повтор не чинит ни испорченный ответ, ни
кривую конфигурацию.
- Любая другая 4xx (400/401/403/404) НЕ ретраится, сразу `TelegramApiError`
(запрос некорректен или прав нет повтор не поможет).
Наружу летит только свой тип: `TelegramApiError` (площадка ответила отказом) или
`TelegramNetworkError` (не ответила), общий предок `TelegramError`. Сырые
httpx-исключения из клиента не выходят: инвариант держат ДВА `except` в
`_request` `httpx.TransportError` (ретраится) и страховочный
`httpx.RequestError` (не ретраится), вместе покрывающие всё дерево отказов
запроса, включая те, что появятся в httpx позже.
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
HTTP-статус и `description` из ответа Telegram токен туда не пишем.
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
@ -32,6 +43,7 @@ from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
import httpx
@ -43,8 +55,199 @@ _DEFAULT_RETRY_AFTER_S = 5.0
_MAX_BACKOFF_S = 30.0
_DEFAULT_MAX_RETRIES = 5
# Telegram документирует ~20 сообщений/минуту на ОДНУ группу (общий лимит на
# все темы супергруппы разом, не на тему по отдельности — превышение даёт 429
# на ЛЮБОЕ следующее сообщение в группу, кто бы его ни отправлял). До #3471
# лимита на нашей стороне не было вовсе: всплеск GlitchTip-алертов + поток
# сообщений поддержки в ту же группу (разные темы, общий чат) укладывались в
# 429 и теряли сообщения — retry в `_request` уважает `retry_after`, но не
# предотвращает сам всплеск. `_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE` — чуть
# ниже площадочного потолка, с запасом на неточность скользящего окна и на то,
# что сама площадка не обязана быть педантичной ровно к 20-й отправке.
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
_RATE_LIMIT_WINDOW_S = 60.0
class TelegramApiError(Exception):
# Текст зондирующего сообщения `verify_chat_and_topic` — см. докстринг там.
# Живёт в чате доли секунды (удаляется сразу после отправки), но должен быть
# узнаваем в логах ретранслятора/дебаге, если удаление вдруг не отработает.
_TOPIC_PROBE_TEXT = "\U0001f50d startup topic check"
class TelegramGroupRateLimiter:
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
Зачем ключ `chat_id`, а не `(chat_id, message_thread_id)`: лимит Telegram
считается на группу целиком, все темы супергруппы делят один бюджет.
Ограничитель с ключом по теме позволил бы двум темам суммарно превысить
лимит группы и всё равно поймать 429 ровно баг, который здесь чинится.
Реализация скользящее окно (список меток времени последних отправок за
`_RATE_LIMIT_WINDOW_S`), а не токен-бакет с фиксированным пополнением:
окно точнее соответствует тому, как Telegram считает лимит («N сообщений
за последние 60 секунд», а не «N сообщений в календарную минуту»).
Конкурентность (asyncio, один процесс, несколько отправителей): на каждый
`chat_id` свой `asyncio.Lock`. Лок держится ВКЛЮЧАЯ время ожидания
(`asyncio.sleep`), а не только на чтение/запись счётчика это осознанно:
цель не просто «не гонять счётчик без гонки», а ФАКТИЧЕСКИ сериализовать
отправителей в этот чат, чтобы они не просыпались все разом по истечении
окна и не били по лимиту повторно.
ВАЖНО про ключ (проверено на проде, review 2026-09-12): `TELEGRAM_SUPPORT_CHAT_ID`
и `TELEGRAM_ALERTS_CHAT_ID` это ОДНА И ТА ЖЕ группа, различаются только
темы (`*_TOPIC_ID`). Именно поэтому ключ лимитера `chat_id`, а НЕ
`(chat_id, message_thread_id)`: поток алертов и поток поддержки сегодня
физически делят один Telegram-бюджет группы, и лимитер обязан это
отражать. Ключ по `chat_id` при этом остаётся корректным и в гипотезе, что
когда-нибудь эти два потока разведут по разным группам, тогда у каждой
просто появится свой независимый лок/бюджет автоматически, без правки кода.
Регистр локов защищён отдельным `asyncio.Lock` только на момент
создания записи сам подсчёт/сон идёт уже под персональным локом чата.
"""
def __init__(
self,
max_per_window: int = _DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE,
window_s: float = _RATE_LIMIT_WINDOW_S,
) -> None:
self._max_per_window = max_per_window
self._window_s = window_s
self._registry_lock = asyncio.Lock()
self._locks: dict[int, asyncio.Lock] = {}
self._sent_at: dict[int, list[float]] = {}
async def _lock_for(self, chat_id: int) -> asyncio.Lock:
async with self._registry_lock:
lock = self._locks.get(chat_id)
if lock is None:
lock = asyncio.Lock()
self._locks[chat_id] = lock
return lock
async def acquire(self, chat_id: int, max_wait: float | None = None) -> None:
"""Блокируется, пока в окне `_window_s` для `chat_id` есть свободный слот.
`max_wait` (review H1, #3471): потолок ожидания очереди. `None` (дефолт)
без потолка, ждать сколько нужно; это ПРАВИЛЬНОЕ поведение для
фоновых отправок бота, где потерять сообщение хуже, чем подождать.
Если задан и слот не появился вовремя бросает `TelegramRateLimitedError`
(честный отказ), а НЕ продолжает ждать: интерактивная HTTP-ручка не
может легально держать открытый запрос браузера дольше своего
собственного таймаута. Вызывающая сторона `TelegramClient._request`,
см. её докстринг про то, откуда берётся конкретное значение.
Логирование (review L1): предупреждение об ожидании пишется РОВНО ОДИН
раз за вызов `acquire` (флаг `warned`), а не на каждой итерации сна
при реальной перегрузке группы это иначе валит лог сотнями одинаковых
строк вместо одного сигнала «была очередь».
Побочный эффект (review M2): перед постановкой в очередь чистит ЧУЖИЕ
полностью просроченные записи в `_sent_at`/`_locks` см. `_cleanup_stale`.
"""
if self._max_per_window <= 0:
return # 0/отрицательное значение конфига = лимитер выключен
now0 = time.monotonic()
await self._cleanup_stale(now0)
deadline = None if max_wait is None else now0 + max_wait
lock = await self._lock_for(chat_id)
async with lock:
warned = False
while True:
now = time.monotonic()
if deadline is not None and now >= deadline:
raise TelegramRateLimitedError(chat_id, max_wait or 0.0)
history = self._sent_at.setdefault(chat_id, [])
cutoff = now - self._window_s
while history and history[0] <= cutoff:
history.pop(0)
if len(history) < self._max_per_window:
history.append(now)
return
wait_s = history[0] + self._window_s - now
if deadline is not None:
wait_s = min(wait_s, max(deadline - now, 0.0))
if not warned:
logger.warning(
"tg group rate limit: chat_id=%s — лимит %d/%.0fs исчерпан, "
"отправки встают в очередь (одно предупреждение на серию)",
chat_id,
self._max_per_window,
self._window_s,
)
warned = True
await asyncio.sleep(max(wait_s, 0.01))
async def _cleanup_stale(self, now: float) -> None:
"""Чистит ЧУЖИЕ (не текущий вызов `acquire`) записи с полностью
просроченной историей (review M2, #3471).
Зачем: `_locks`/`_sent_at` ключуются по ЛЮБОМУ `chat_id`, включая
личные чаты каждого клиента бота (`bridge.py` зеркалит их 1:1 через тот
же `TelegramClient`) большинство из них шлют боту одно сообщение и
больше никогда не возвращаются. Без чистки оба словаря растут
монотонно на всё время жизни долгоживущего процесса (медленная утечка).
Безопасность удаления: лок пропускаем, если `lock.locked()` значит
кто-то ИМЕННО СЕЙЧАС работает с этим `chat_id`, трогать нельзя. Если
лок свободен и вся история старше окна запись безвредно удалить:
следующий `acquire` для того же `chat_id` просто создаст её заново
пустой (`setdefault`), с тем же результатом, что и не удаляй мы её.
"""
cutoff = now - self._window_s
async with self._registry_lock:
stale = [cid for cid, ts in self._sent_at.items() if not ts or ts[-1] <= cutoff]
for cid in stale:
lock = self._locks.get(cid)
if lock is not None and lock.locked():
continue
self._sent_at.pop(cid, None)
self._locks.pop(cid, None)
# Раздельные таймауты вместо скаляра. httpx разворачивает скаляр в
# connect=read=write=pool, поэтому long-poll `getUpdates` (read = 30с, которые
# Telegram держит запрос, + 10с запаса = 40с) ставил 40 секунд и на УСТАНОВКУ
# соединения. Живой connect до api.telegram.org из прод-контейнера занимает
# 0.036с — 40-секундное ожидание коннекта было чистой слепотой: худший цикл
# 4 попытки × 40с + backoff ≈ 174с, и всё это время бот не видит ответов
# оператора (замер 12.09.2026: разрывы в логе 06:40:10 → 06:42:22 → 06:43:35,
# 576 строк `network error` и 7 полных исчерпаний бюджета ретраев за сутки).
# connect/write/pool к ожиданию ОТВЕТА Telegram отношения не имеют и коротки.
_CONNECT_TIMEOUT_S = 5.0
_WRITE_TIMEOUT_S = 10.0
_POOL_TIMEOUT_S = 5.0
# Пул keep-alive соединений на ОДИН экземпляр клиента. Параллелизма тут почти
# нет (long-polling — один запрос за раз, интерактивные ручки — единицы в
# минуту), так что смысл пула не в ширине, а в том, чтобы TCP+TLS-хендшейк не
# повторялся на каждый запрос и каждый ретрай.
_MAX_KEEPALIVE_CONNECTIONS = 5
_MAX_CONNECTIONS = 10
# Сколько держать простаивающее соединение. Задаём ЯВНО, потому что дефолт
# httpx — 5 секунд, и с ним пул не давал бы ничего там, где он нужнее всего:
# poll loop переиспользует соединение (следующий getUpdates уходит сразу), а
# вот веб-поддержка шлёт сообщения раз в минуты — за 5с соединение протухает и
# каждое зеркало снова платит полный TCP+TLS.
#
# Плата за длинный keep-alive — возросший шанс взять из пула соединение, которое
# уже закрыла та сторона; httpx отдаёт это как `RemoteProtocolError` («Server
# disconnected without sending a response»). Он ретраится с #3457, так что
# сценарий закрыт: попытка на протухшем соединении стоит один повтор, а не отказ.
_KEEPALIVE_EXPIRY_S = 90.0
class TelegramError(Exception):
"""Общий предок отказов клиента: и «ответил ok: false», и «не ответил вовсе».
Нужен ровно затем, чтобы вызывающий мог одной строкой сказать «Telegram не
сработал» и отдать свой 502. До #3456 сетевой отказ прилетал наружу сырым
`httpx.ConnectTimeout`, мимо `except TelegramApiError`, и FastAPI отдавал
500 см. `TelegramNetworkError`.
"""
class TelegramApiError(TelegramError):
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
def __init__(self, method: str, error_code: int, description: str) -> None:
@ -54,6 +257,59 @@ class TelegramApiError(Exception):
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
class TelegramNetworkError(TelegramError):
"""Ответа от Telegram не было: таймаут/обрыв, ретраи исчерпаны.
Отдельный тип, а не `TelegramApiError`, потому что `error_code`/`description`
брать неоткуда Telegram ничего не сказал. Вызывающие, которым важна ТОЛЬКО
реакция площадки (`bridge`, разбирающий 403 «бот заблокирован»), продолжают
ловить `TelegramApiError` и этот отказ не перехватывают.
Причина сохраняется в `__cause__`: в GlitchTip виден исходный httpx-класс,
по которому и отличают таймаут соединения от сброса TLS (#3156).
"""
def __init__(self, method: str, reason: str, attempts: int) -> None:
self.method = method
self.reason = reason
self.attempts = attempts
super().__init__(f"Telegram {method} unreachable after {attempts} attempts: {reason}")
class TelegramRateLimitedError(TelegramError):
"""Слот в `TelegramGroupRateLimiter` не появился за `max_wait` секунд (review H1).
Бросается ТОЛЬКО когда вызывающий явно (или неявно, через `timeout`)
попросил ограниченное ожидание фоновые вызовы без такого ограничения
ждут очередь сколько нужно и этого исключения никогда не увидят. Общий
предок `TelegramError` существующие `except TelegramError` в
`app.api.v1.support`/`glitchtip` подхватывают этот отказ автоматически, без
правки самих ручек, и отвечают своим честным 502 вместо зависшего запроса.
"""
def __init__(self, chat_id: int, max_wait: float) -> None:
self.chat_id = chat_id
self.max_wait = max_wait
super().__init__(
f"Telegram group rate limit: no slot for chat_id={chat_id} within {max_wait:.1f}s"
)
def _request_timeout(read: float) -> httpx.Timeout:
"""Разворачивает «сколько ждать ответа» (скаляр вызывающего) в таймауты httpx.
`read` запрошенный бюджет ОТВЕТА (для long-poll это `poll_timeout + 10s`);
connect/write/pool фиксированы модульными константами и коротки: ждать
ответа Telegram не то же самое, что ждать установки соединения.
"""
return httpx.Timeout(
connect=_CONNECT_TIMEOUT_S,
read=read,
write=_WRITE_TIMEOUT_S,
pool=_POOL_TIMEOUT_S,
)
def _extract_retry_after(
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
) -> float:
@ -87,16 +343,111 @@ def _error_from_body(response: httpx.Response) -> tuple[int, str]:
class TelegramClient:
"""Bot API клиент на httpx.AsyncClient. Каждый вызов — отдельное короткоживущее соединение."""
"""Bot API клиент поверх ОДНОГО долгоживущего `httpx.AsyncClient`.
Соединение переиспользуется всё время жизни экземпляра: `AsyncClient`
создаётся лениво при первом запросе и хранится в `self._http`. Раньше он
создавался ВНУТРИ цикла ретраев то есть keep-alive не было вовсе: полный
TCP+TLS-хендшейк на каждый запрос и на каждую повторную попытку, и заново
кидался кубик «встанет ли коннект». Для long-polling'а, ходящего каждые
~30с в бесконечном цикле, это была основная статья сетевых отказов.
Отсюда требование к вызывающим: экземпляр НАДО переиспользовать (один на
процесс воркера, один на FastAPI-приложение см.
`app.services.tgbot.shared`), а не создавать на каждый запрос, и закрывать
через `aclose()` или `async with`.
Таймаут теперь per-request: у `AsyncClient` он стоит дефолтом, а каждый
вызов `_request` передаёт свой `httpx.Timeout` (long-poll свои 40с на
read, интерактивные ручки свой узкий бюджет).
"""
def __init__(
self,
token: str,
base_url: str = "https://api.telegram.org",
timeout: float = _DEFAULT_TIMEOUT_S,
relay_base_url: str = "",
relay_secret: str = "",
group_rate_limit_per_minute: int = _DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE,
) -> None:
self._base = f"{base_url}/bot{token}"
# ── Ретранслятор через Beget (#3471) ────────────────────────────────
# `relay_base_url` пуст по умолчанию → `_relay_base is _direct_base`,
# и `_post` ниже не делает второй попытки (фолбэчить с прямого пути
# НА прямой же путь бессмысленно). Заданный адрес переключает основной
# путь на ретранслятор, прямой остаётся ЗАПАСНЫМ на случай его отказа.
self._direct_base = f"{base_url}/bot{token}"
self._relay_base = f"{relay_base_url}/bot{token}" if relay_base_url else self._direct_base
self._relay_secret = relay_secret
self._base = self._relay_base
self._timeout = timeout
self._http: httpx.AsyncClient | None = None
# Один лимитер на экземпляр клиента — см. `TelegramGroupRateLimiter`.
# Ретранслятор здесь ни при чём: лимитер стоит ДО `_post`, то есть
# считает отправку независимо от того, уйдёт она через relay или
# напрямую (#3471) — оба пути ниже по стеку от этой точки.
self._rate_limiter = TelegramGroupRateLimiter(group_rate_limit_per_minute)
def _http_client(self) -> httpx.AsyncClient:
"""Ленивое создание переиспользуемого AsyncClient (вне цикла ретраев)."""
if self._http is None:
self._http = httpx.AsyncClient(
timeout=_request_timeout(self._timeout),
limits=httpx.Limits(
max_keepalive_connections=_MAX_KEEPALIVE_CONNECTIONS,
max_connections=_MAX_CONNECTIONS,
keepalive_expiry=_KEEPALIVE_EXPIRY_S,
),
)
return self._http
async def aclose(self) -> None:
"""Закрывает пул соединений. Идемпотентно; после — клиент снова ленив."""
http, self._http = self._http, None
if http is not None:
await http.aclose()
async def __aenter__(self) -> TelegramClient:
return self
async def __aexit__(self, *_exc: object) -> None:
await self.aclose()
async def _post(
self,
client: httpx.AsyncClient,
url: str,
payload: dict[str, Any],
timeout: httpx.Timeout,
) -> httpx.Response:
"""POST с фолбэком на прямой путь при отказе РЕТРАНСЛЯТОРА (#3471).
Когда ретранслятор не настроен, `_relay_base is _direct_base`, `via_relay`
ниже всегда `False`, и метод ведёт себя как простой `client.post` этот
путь ничем не отличается от поведения до #3471 (механизм отката).
Когда настроен: `url` бьёт в `self._relay_base`. Транспортный отказ (не
ответ Telegram ЧЕРЕЗ ретранслятор, а отказ ДО него TCP/TLS до самого
relay-хоста) даёт РОВНО ОДНУ попытку напрямую к api.telegram.org не
рекурсивно: если недоступен и прямой путь, исключение поднимается как
обычно и подхватывается retry-циклом `_request` на общих основаниях (со
следующей попытки цикл снова пробует ретранслятор first временный
сбой relay не должен постоянно понижать клиента до прямого пути).
"""
via_relay = self._relay_base != self._direct_base and url.startswith(self._relay_base)
headers = (
{"X-Relay-Secret": self._relay_secret} if via_relay and self._relay_secret else None
)
try:
return await client.post(url, json=payload, timeout=timeout, headers=headers)
except httpx.TransportError:
if not via_relay:
raise
direct_url = self._direct_base + url[len(self._relay_base) :]
logger.warning(
"tg client: ретранслятор недоступен, одна попытка напрямую к Telegram"
)
return await client.post(direct_url, json=payload, timeout=timeout)
async def _request(
self,
@ -106,9 +457,24 @@ class TelegramClient:
timeout: float | None = None,
max_retries: int = _DEFAULT_MAX_RETRIES,
max_backoff: float | None = None,
rate_limit_max_wait: float | None = None,
) -> Any:
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу.
`rate_limit_max_wait` (review H1/M1, #3471) — потолок ожидания слота в
`TelegramGroupRateLimiter.acquire`. Приоритет:
1. Явный `rate_limit_max_wait` используется как есть (bridge.py
передаёт его точечно для конкретных мест, см. `_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S`).
2. Иначе, если вызывающий передал явный `timeout` используем
`effective_timeout` КАК ЕСТЬ. Интерактивные ручки (`app.api.v1.support`,
`app.api.v1.glitchtip`) и так ОБЯЗАНЫ передавать узкий `timeout`
(5-8с, см. их собственные докстринги) этого достаточно, чтобы
очередь лимитера не держала открытый HTTP-запрос браузера дольше
его же собственного бюджета, БЕЗ дополнительной правки этих ручек.
3. Иначе `None` без потолка. Это дефолт для фоновых отправок бота
(`app.tgbot_main`/`bridge.py` без явного `timeout`), где потерять
сообщение хуже, чем подождать дольше.
`max_backoff` (#tgsupport-retry) — потолок паузы МЕЖДУ попытками. По
умолчанию `_MAX_BACKOFF_S` (30с) и полный `retry_after` из тела 429 это
воркерная политика, она НЕ меняется. Интерактивный вызывающий передаёт узкий
@ -122,17 +488,51 @@ class TelegramClient:
это штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на
минуты ровно то, от чего предостерегает докстринг `send_message`.
"""
url = f"{self._base}/{method}"
effective_timeout = timeout if timeout is not None else self._timeout
# Лимитер применяется ТОЛЬКО к методам с `chat_id` в payload (отправка
# в конкретный чат) — `getUpdates` его не несёт и лимиту не подлежит.
# Списывается ОДИН слот на логический вызов `_request` (то есть на
# одну попытку отправки конкретного сообщения), а не на каждую HTTP
# попытку внутри ретрай-цикла ниже: ретраи по 429/5xx лечат один и тот
# же send, а не порождают новые отправки. Приоритет `wait_cap` — см.
# докстринг параметра `rate_limit_max_wait` выше.
chat_id = payload.get("chat_id")
if isinstance(chat_id, int):
wait_cap = rate_limit_max_wait
if wait_cap is None and timeout is not None:
wait_cap = effective_timeout
await self._rate_limiter.acquire(chat_id, max_wait=wait_cap)
url = f"{self._base}/{method}"
# Раздельные таймауты считаем ОДИН раз и передаём per-request: у общего
# AsyncClient свой дефолт, а бюджет ответа у каждого вызова свой.
request_timeout = _request_timeout(effective_timeout)
backoff_cap = _MAX_BACKOFF_S if max_backoff is None else max_backoff
attempt = 0
# Клиент берём ДО цикла: пересоздавать его на каждую попытку значило бы
# заново платить за TCP+TLS ровно там, где сеть уже показала себя плохо.
client = self._http_client()
while True:
attempt += 1
try:
async with httpx.AsyncClient(timeout=effective_timeout) as client:
response = await client.post(url, json=payload)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
response = await self._post(client, url, payload, request_timeout)
except httpx.TransportError as exc:
# Ловим ВЕСЬ `TransportError`, а не узкий кортеж
# `(TimeoutException, NetworkError)`: `RemoteProtocolError`
# («Server disconnected without sending a response» — бытовой
# ответ api.telegram.org из РФ), `ProxyError`,
# `LocalProtocolError` и `UnsupportedProtocol` — СЁСТРЫ
# `NetworkError` по `TransportError`, а не наследники. Кортеж
# оставлял дыру ровно того класса, который чинил #3456: отказ
# вылетал сырым httpx мимо `except TelegramError` в ручках и
# снова давал 500 вместо 502 — и вдобавок не ретраился ни разу.
# Расширение ретраев на `RemoteProtocolError` наследует уже
# принятый здесь риск at-least-once (запрос мог дойти до
# Telegram, потерялся ответ) — он тот же, что у давно
# ретраящегося `ReadTimeout`; политика не меняется.
#
# Тип исключения обязан попасть в строку (#3156). У
# httpx.ReadError и httpx.ConnectError `str(exc)` пуст, и лог
# выглядел так: «network error (попытка 1/3): — retry через 2s»
@ -148,7 +548,7 @@ class TelegramClient:
attempt,
reason,
)
raise
raise TelegramNetworkError(method, reason, attempt) from exc
backoff = min(2.0**attempt, backoff_cap)
logger.warning(
"tg client: %s — network error (попытка %d/%d): %s — retry через %.1fs",
@ -160,6 +560,26 @@ class TelegramClient:
)
await asyncio.sleep(backoff)
continue
except httpx.RequestError as exc:
# Страховка на остаток дерева отказов запроса: сегодня это
# `DecodingError` (битая компрессия в ответе), завтра — всё, что
# httpx заведёт под `RequestError`. `TooManyRedirects` сюда НЕ
# относится: клиент создаётся с дефолтным `follow_redirects=False`
# и редиректы не ходит. Порядок `except`-ов
# значим: `TransportError` — наследник `RequestError`, и стоять
# обязан ВЫШЕ, иначе сетевые отказы перестали бы ретраиться.
#
# Без ретраев намеренно: это не «площадка недоступна», а
# испорченный ответ или кривая конфигурация — повтор не лечит
# ни то, ни другое, а пять попыток с backoff подвесили бы
# интерактивную ручку почти на минуту впустую. Свой тип тут
# нужен ровно за тем же, за чем и выше: чтобы ручка увидела
# `TelegramError` и отдала 502, а не 500.
reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
logger.error(
"tg client: %s — запрос не состоялся (без ретраев): %s", method, reason
)
raise TelegramNetworkError(method, reason, attempt) from exc
if response.status_code == 429:
retry_after = _extract_retry_after(response)
@ -232,8 +652,11 @@ class TelegramClient:
) -> list[dict[str, Any]]:
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
HTTP-таймаут запроса берётся с запасом (`timeout + 10s`), чтобы не обрывать
соединение раньше, чем ответит сам Telegram long-poll.
Запас `+10s` относится к READ-таймауту (сколько ждём ответа), чтобы не
обрывать соединение раньше, чем ответит сам Telegram long-poll. На
connect/write/pool он НЕ распространяется они короткие и фиксированы
(`_CONNECT_TIMEOUT_S` и соседи): установка соединения либо занимает
десятки миллисекунд, либо не состоится вовсе.
"""
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
if allowed_updates is not None:
@ -251,8 +674,12 @@ class TelegramClient:
message_id: int,
message_thread_id: int | None = None,
reply_to_message_id: int | None = None,
rate_limit_max_wait: float | None = None,
) -> dict[str, Any]:
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла.
`rate_limit_max_wait` см. `TelegramClient._request`; используется
`bridge.py` для точечного потолка ожидания на конкретных местах (review M1)."""
payload: dict[str, Any] = {
"chat_id": chat_id,
"from_chat_id": from_chat_id,
@ -262,7 +689,9 @@ class TelegramClient:
payload["message_thread_id"] = message_thread_id
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
result = await self._request("copyMessage", payload)
result = await self._request(
"copyMessage", payload, rate_limit_max_wait=rate_limit_max_wait
)
return result if isinstance(result, dict) else {}
async def send_message(
@ -275,6 +704,7 @@ class TelegramClient:
timeout: float | None = None,
max_retries: int | None = None,
max_backoff: float | None = None,
rate_limit_max_wait: float | None = None,
) -> dict[str, Any]:
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
@ -298,5 +728,131 @@ class TelegramClient:
kwargs["max_retries"] = max_retries
if max_backoff is not None:
kwargs["max_backoff"] = max_backoff
if rate_limit_max_wait is not None:
kwargs["rate_limit_max_wait"] = rate_limit_max_wait
result = await self._request("sendMessage", payload, **kwargs)
return result if isinstance(result, dict) else {}
async def get_chat(self, chat_id: int) -> dict[str, Any]:
"""getChat — метаданные чата. Единственная цель здесь — startup-проверка
(см. `verify_chat_and_topic`): подтвердить, что `chat_id` валиден и бот
не выгнан/не заблокирован, без единого видимого сообщения."""
result = await self._request("getChat", {"chat_id": chat_id}, max_retries=1)
return result if isinstance(result, dict) else {}
async def send_chat_action(
self,
*,
chat_id: int,
action: str = "typing",
message_thread_id: int | None = None,
) -> bool:
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
НЕ провалидировано для проверки `message_thread_id`: живой прод-замер
(#3471) показал, что Telegram принимает и мгновенно подтверждает
`sendChatAction` с ЗАВЕДОМО несуществующим `message_thread_id`
(`{"ok":true,"result":true}`) метод молча принимает любую тему,
существующую или нет. Раньше здесь было обратное (неверное)
утверждение; см. `verify_chat_and_topic`, которая для проверки темы
использует `sendMessage`+`deleteMessage`."""
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
if message_thread_id:
payload["message_thread_id"] = message_thread_id
result = await self._request(
"sendChatAction", payload, max_retries=1, max_backoff=5.0
)
return bool(result)
async def delete_message(self, *, chat_id: int, message_id: int) -> bool:
"""deleteMessage — удаляет сообщение бота.
Используется `verify_chat_and_topic` для зачистки зондирующего
`sendMessage`-пробника сразу после проверки темы: тема существует
тогда и только тогда, когда сообщение вообще удалось отправить
поэтому к моменту вызова `deleteMessage` id уже гарантированно
валиден."""
result = await self._request(
"deleteMessage",
{"chat_id": chat_id, "message_id": message_id},
max_retries=1,
max_backoff=5.0,
)
return bool(result)
async def verify_chat_and_topic(
client: TelegramClient,
*,
chat_id: int,
topic_id: int,
label: str,
) -> bool:
"""Разовая startup-проверка: чат существует, бот в нём не забанен, тема жива.
Зачем нужна: бот пишет в тему форума по числовому id из настроек. Если
тему удалили, переименовали в другую (новый id) или id в конфиге просто
неверный отправка начинает падать НА КАЖДОМ сообщении, а узнаём мы об
этом только по молчанию у людей (симметрично истории #tgsupport с сетевыми
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
«через сутки тишины» на «в первую секунду после старта/рестарта».
Способ: `getChat(chat_id)` + `sendMessage`/`deleteMessage` в саму тему.
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
выгнан/не заблокирован чистый read, нулевой видимый след.
- Раньше здесь стоял `send_chat_action` (typing-индикатор с
`message_thread_id`) в расчёте на то, что Telegram обязан знать про
`message_thread_id`, чтобы показать «печатает...» именно в нужном
треде. Живой прод-замер (#3471) опроверг это: `sendChatAction`
принимает и подтверждает ЗАВЕДОМО несуществующий
`message_thread_id` (`{"ok":true,"result":true}`) проверка была
ложно-зелёной ВСЕГДА, удалённая/переименованная тема проходила её
так же, как живая, а реальная отправка потом падала на каждом
сообщении. `sendMessage` с тем же мусорным id вместо этого мгновенно
отвечает `400 Bad Request: message thread not found` (эмпирически
подтверждено на том же проде), а с валидным id создаёт сообщение
которое здесь же стирается `deleteMessage`, так что в истории треда
не остаётся ничего, кроме микросекундного технического сообщения на
каждом старте/рестарте контейнера.
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
уровня error задача явно требует шума в логе, а не падения воркера
(тема пуста/невалидна это деградация уведомлений, а не фатальный сбой
самого бота, который всё ещё должен принимать входящие).
`chat_id == 0` (не настроено) считается успехом без обращения к API:
это штатный kill-switch (см. `app.core.config`), а не ошибка конфигурации.
"""
if not chat_id:
return True
try:
await client.get_chat(chat_id)
if topic_id:
probe = await client.send_message(
chat_id=chat_id,
text=_TOPIC_PROBE_TEXT,
message_thread_id=topic_id,
max_retries=1,
max_backoff=5.0,
)
probe_message_id = probe.get("message_id")
if probe_message_id:
await client.delete_message(chat_id=chat_id, message_id=probe_message_id)
except TelegramError as exc:
logger.error(
"tg topic check [%s]: чат/тема недоступны для отправки "
"(chat_id=%s, topic_id=%s) — %s. Сообщения в эту тему БУДУТ "
"падать, пока конфигурация не исправлена.",
label,
chat_id,
topic_id,
exc,
)
return False
logger.info(
"tg topic check [%s]: чат и тема доступны для отправки (chat_id=%s, topic_id=%s)",
label,
chat_id,
topic_id,
)
return True

View file

@ -0,0 +1,58 @@
"""Общий на приложение `TelegramClient` (#tg-connection-resilience).
Зачем: до этого три HTTP-ручки (`api.v1.support` ×2, `api.v1.glitchtip`) делали
`TelegramClient(token)` на КАЖДЫЙ входящий запрос, а клиент внутри пересоздавал
`httpx.AsyncClient` на каждую попытку то есть keep-alive не было ни на каком
уровне и каждый запрос начинался с полного TCP+TLS-хендшейка до
api.telegram.org. Здесь живёт один экземпляр на процесс: создаётся в lifespan
(`app.main`), закрывается на shutdown там же.
Воркер бота (`app.tgbot_main`) сюда НЕ ходит у него свой процесс без ASGI и
свой экземпляр на всё время жизни поллинга.
"""
from __future__ import annotations
import logging
from app.core.config import settings
from app.services.tgbot.client import TelegramClient
logger = logging.getLogger(__name__)
_client: TelegramClient | None = None
def get_telegram_client() -> TelegramClient:
"""Общий клиент приложения. Ленив: создаётся при первом обращении.
Ленивость (а не «только из lifespan») нужна из-за kill-switch: при пустом
`TELEGRAM_BOT_TOKEN` в lifespan создавать нечего, а тесты ручек поднимают
приложение без прохода через startup.
"""
global _client
if _client is None:
_client = TelegramClient(
settings.telegram_bot_token,
relay_base_url=settings.telegram_relay_base_url,
relay_secret=settings.telegram_relay_secret,
# API-роль (review H2, #3471) — см. докстринг настройки в
# app.core.config: бюджет группы разделён статически между этим
# процессом и app.tgbot_main, суммарно ниже площадочного лимита.
group_rate_limit_per_minute=settings.telegram_group_rate_limit_api_per_minute,
)
return _client
def init_telegram_client() -> TelegramClient:
"""Явное создание на старте приложения (lifespan)."""
return get_telegram_client()
async def close_telegram_client() -> None:
"""Закрывает общий клиент на shutdown. Идемпотентно."""
global _client
client, _client = _client, None
if client is not None:
await client.aclose()
logger.info("tg shared client: пул соединений закрыт")

View file

@ -61,6 +61,36 @@ def get_or_create_thread(db: Session, username: str) -> int:
return int(row[0])
def find_inbound_by_idempotency_key(
db: Session, *, thread_id: int, idempotency_key: str
) -> dict[str, Any] | None:
"""Уже записанное inbound-сообщение с этим ключом идемпотентности в треде,
если есть (#3471). Вызывается ИЗ `app.api.v1.support` ДО похода в Telegram
(`send_support_message` / `send_anon_support_message`) повтор с тем же
ключом не должен создавать второе зеркало в топике, а не только вторую
строку в БД. `thread_id`, а не username/anon-token: таблица не хранит
identity напрямую, а тред уже гарантированно существует к моменту, когда
этот ключ мог быть записан (тред создаётся ДО `record_inbound`, см. H1 в
докстринге `app.api.v1.support`)."""
row = (
db.execute(
text(
"""
SELECT id, direction, text_body, operator_tg_id, created_at
FROM web_support_messages
WHERE thread_id = CAST(:thread_id AS bigint)
AND direction = 'in'
AND idempotency_key = :idempotency_key
"""
),
{"thread_id": thread_id, "idempotency_key": idempotency_key},
)
.mappings()
.one_or_none()
)
return dict(row) if row is not None else None
def record_inbound(
db: Session,
*,
@ -68,6 +98,7 @@ def record_inbound(
text_body: str,
topic_message_id: int | None,
support_chat_id: int | None,
idempotency_key: str | None = None,
) -> dict[str, Any]:
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
@ -75,18 +106,103 @@ def record_inbound(
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
support-группе если группу когда-нибудь сменят/пересоздадут, Telegram
message_id стартует заново с 1 в новом чате и может совпасть с числом из
старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188)."""
старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188).
`idempotency_key` (#3471, миграция 301) — вызывающая сторона (`app.api.v1.support`)
делает SELECT-затем-действие pre-check ДО Telegram-похода (см.
`find_inbound_by_idempotency_key`), но этот pre-check САМ ПО СЕБЕ гонку не
закрывает (TOCTOU): два запроса с одним ключом могут пройти его одновременно
и оба уйти в Telegram. Последняя линия защиты здесь: `INSERT ... ON
CONFLICT (thread_id, idempotency_key) DO NOTHING` на partial unique индексе
(мигр. 301, тот же predicate). Если конфликт всё же случился, проигравший
НЕ создаёт вторую строку читает уже вставленную и возвращает её, так что
оба запроса-конкурента получают ОДИН и тот же id. `idempotency_key=None`
(дефолт) ведёт себя как раньше: NULL никогда не конфликтует сам с собой в
partial-индексе (WHERE idempotency_key IS NOT NULL), INSERT всегда проходит."""
row = (
db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, created_at)
support_chat_id, operator_tg_id, idempotency_key, created_at)
VALUES
(CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, NOW())
CAST(:support_chat_id AS bigint), NULL, :idempotency_key, NOW())
ON CONFLICT (thread_id, idempotency_key)
WHERE idempotency_key IS NOT NULL AND direction = 'in'
DO NOTHING
RETURNING id, direction, text_body, operator_tg_id, created_at
"""
),
{
"thread_id": thread_id,
"text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
"idempotency_key": idempotency_key,
},
)
.mappings()
.one_or_none()
)
if row is not None:
return dict(row)
# Конфликт пойман индексом — idempotency_key почти наверняка не NULL здесь
# (при NULL partial-индекс в конфликт не участвует), но НЕ `assert`: это
# прод-путь ПОСЛЕ уже доставленного в Telegram сообщения (см. H1 в
# app.api.v1.support), а `except SQLAlchemyError` в вызывающей стороне
# `AssertionError` не ловит — под `python -O` assert вдобавок исчезает
# молча и `existing` осталось бы `None`, что развалилось бы чуть ниже при
# сборке ответа. Вместо падения — явная ветка с логом и WORKING откатом.
existing = (
find_inbound_by_idempotency_key(db, thread_id=thread_id, idempotency_key=idempotency_key)
if idempotency_key is not None
else None
)
if existing is not None:
# Ожидаемый случай гонки (#3471): проигравший запрос уже отправил своё
# СОБСТВЕННОЕ зеркало в Telegram (свой topic_message_id) до того, как
# обнаружил конфликт здесь — эта копия зеркала теперь осиротела
# (реплай оператора на неё никуда не смаршрутизируется, т.к. строки
# для неё в БД нет). Осознанно не чиним это в этом PR (см. коммент
# выше по коду), но фиксируем в логе, а не молчим — ровно то же самое,
# для чего уже есть `_warn_operator_message_not_recorded` в другом месте.
logger.warning(
"web support: гонка по idempotency_key — зеркало topic_message_id=%s "
"(thread_id=%d) отправлено, но НЕ записано, победила строка id=%d",
topic_message_id,
thread_id,
existing["id"],
)
return existing
# Крайний случай (в норме недостижим при READ COMMITTED, которую использует
# этот сервис): индекс сообщил о конфликте, но повторное чтение строку не
# нашло. Логируем и пишем БЕЗ идемпотентности — NULL-ключ никогда не
# конфликтует сам с собой (partial-индекс его не видит), INSERT гарантированно
# пройдёт; для этого одного сообщения дедупликация отключается, но клиент
# получает корректный ответ вместо 500 после уже состоявшейся доставки.
logger.warning(
"web support: ON CONFLICT сообщил о конфликте (thread_id=%d, "
"idempotency_key=%s), но повторное чтение строки её не нашло — "
"записываем без идемпотентности",
thread_id,
"<set>" if idempotency_key is not None else None,
)
row = (
db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, idempotency_key, created_at)
VALUES
(CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, NULL, NOW())
RETURNING id, direction, text_body, operator_tg_id, created_at
"""
),
@ -106,8 +222,17 @@ def record_inbound(
def find_thread_by_topic_message(
db: Session, topic_message_id: int, support_chat_id: int
) -> int | None:
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
"""Резолвит id зеркала/ответа (reply_to) в thread_id.
БЕЗ фильтра по direction (#3471 P0, было `AND direction = 'in'`): с тех пор
как `record_outbound` тоже сохраняет `topic_message_id` (id ответа оператора
В ТОПИКЕ), реплай оператора на СВОЙ предыдущий ответ обязан резолвиться так
же, как реплай на inbound-зеркало клиента иначе продолжение диалога без
повторного цитирования клиента тихо проваливалось в orphan-check
(`_handle_group_reply` в bridge.py). Коллизий topic_message_id между
inbound- и outbound-строками одного треда быть не может: Telegram выдаёт
каждому сообщению в чате свой возрастающий id, `web_support_messages_topic_message_id_uq`
(partial unique, 187) это же и гарантирует на уровне БД.
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
ЧУЖИМ (не NULL и не текущим) support_chat_id это исторический артефакт
@ -120,7 +245,6 @@ def find_thread_by_topic_message(
SELECT thread_id
FROM web_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
ORDER BY created_at DESC
LIMIT 1
@ -132,18 +256,40 @@ def find_thread_by_topic_message(
def record_outbound(
db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
db: Session,
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
) -> int | None:
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
`topic_message_id` всегда NULL маршрутизирующий ключ живёт только на
inbound-записи (см. tg_support_messages-конвенцию, 186)."""
`topic_message_id` (#3471 P0, раньше был безусловно NULL) — id ЭТОГО
сообщения оператора в топике. Раньше маршрутизирующий ключ жил только на
inbound-записи (конвенция 186/187), из-за чего реплай оператора на СВОЙ
предыдущий ответ был нерезолвим искать было нечего, а `reply_to_message_id`
указывал на строку без ключа. `find_thread_by_topic_message` теперь матчит
обе стороны (direction-фильтр там снят).
`support_chat_id` (deep review PR #3479) — ОБЯЗАТЕЛЕН при заполненном
`topic_message_id`: `find_thread_by_topic_message` матчит `support_chat_id
IS NULL` как лениентный wildcard "под любым текущим чатом" (легаси-строки
до 187/188). Без этого поля КАЖДАЯ out-строка была бы таким wildcard при
ротации support-группы новый message_id мог бы совпасть со старой
out-строкой и увести ответ в ЧУЖОЙ тред (ровно то, от чего защищала
скоупинг-миграция 187/188, см. review M1 там же)."""
row = db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, created_at)
VALUES
(CAST(:thread_id AS bigint), 'out', :text_body, NULL,
(CAST(:thread_id AS bigint), 'out', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint),
CAST(:operator_tg_id AS bigint), NOW())
RETURNING id
"""
@ -151,6 +297,8 @@ def record_outbound(
{
"thread_id": thread_id,
"text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
"operator_tg_id": operator_tg_id,
},
).fetchone()

View file

@ -141,7 +141,7 @@ async def backfill_yandex_addresses(
# нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module
# docstring). Явный пропуск run'а вместо слепого прохода через egress, который
# мог быть источником текущего инцидента.
logger.error(
logger.warning(
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
"пропущен, ни один листинг не обработан",
exc,

View file

@ -51,6 +51,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import LISTINGS_FRESH_DAYS, settings
from app.services import regions as regions_mod
from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
@ -68,13 +69,29 @@ _PPM2_MIN: int = 30_000
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
# ниже) — симметрично deal-стороне.
#
# #3512 (per-region ratio, migration 304): эта городская квота — костыль ИМЕННО региона
# 66 (asking-скрейп исторически покрывал только сам ЕКБ, см. #C2 выше), и трогать её
# нельзя — числа региона 66 обязаны остаться byte-for-byte прежними. Для ЛЮБОГО другого
# региона (deals.region_code / listings.region_code — обе таблицы несут колонку) городской
# квоты не было и не нужно: там достаточно `region_code = :region_code` симметрично на
# обеих сторонах (см. _REDERIVE_SQL_REGION ниже) — колонка `district` остаётся
# зарезервированной под #647 (гео-районы ВНУТРИ региона), per-region разрез теперь
# несёт `region_code`, а не `district`.
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
# #3512: регионы, для которых считается ОТДЕЛЬНАЯ (не-ЕКБ) деривация ниже —
# весь реестр покрытия (app.services.regions — ЕДИНСТВЕННЫЙ источник правды про регионы)
# минус 66 (у него своя историческая деривация выше). Появление нового региона в реестре
# автоматически включает его в пересчёт ratio, без правки этого файла.
_OTHER_REGION_CODES: tuple[int, ...] = tuple(
sorted(code for code in regions_mod.REGIONS if code != 66)
)
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
@ -108,14 +125,26 @@ def area_bucket(area_m2: float) -> int:
logger = logging.getLogger(__name__)
# ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
# ── True-mirror cleanup: drop all #648 EKB rows before re-derivation ──────────
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы
# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы
# только что вставленные строки другого региона на повторном прогоне того же transaction).
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
_DELETE_SQL = text(
"""
DELETE FROM asking_to_sold_ratios
WHERE district = ''
WHERE region_code = 66 AND district = ''
"""
)
# #3512: та же true-mirror очистка, но per-region (используется в цикле для
# _OTHER_REGION_CODES) — CAST(:region_code AS int), никогда :region_code::int (psycopg v3).
_DELETE_SQL_REGION = text(
"""
DELETE FROM asking_to_sold_ratios
WHERE region_code = CAST(:region_code AS int) AND district = ''
"""
)
@ -266,19 +295,133 @@ _REDERIVE_SQL = text(
)
INSERT INTO asking_to_sold_ratios (
rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis
n_deals, n_listings, window_months, basis, region_code
)
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM global_row
n_deals, n_listings, window_months, basis, 66 FROM global_row
UNION ALL
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM per_bucket
n_deals, n_listings, window_months, basis, 66 FROM per_bucket
"""
)
# ── Generic per-region derivation (#3512) ─────────────────────────────────────
# Байт-в-байт та же деривация, что _REDERIVE_SQL выше (тот же 12-мес трейлинг,
# ppm²-полоса, novostroyki/freshness/area_m2-гарды, area-бакет ask-стороны, порог
# 30/30, global -1 fallback) — ЕДИНСТВЕННОЕ отличие: городская квота ЕКБ (city ILIKE /
# city IS NULL OR city ILIKE) заменена на симметричный `region_code = :region_code`
# на ОБЕИХ сторонах. Городской квоты здесь не было и не нужно: она была костылём
# конкретно исторического asking-покрытия ЕКБ (#C2), а не общим правилом.
_REDERIVE_SQL_REGION = text(
f"""
WITH
deal_side AS (
SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND region_code = CAST(:region_code AS int)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
ask_side AS (
SELECT
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM listings
WHERE is_active
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS int)
GROUP BY {_AREA_ROOMS_BUCKET_SQL}
),
per_bucket AS (
SELECT
d.rooms_bucket,
''::text AS district,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals::int AS n_deals,
a.n_listings::int AS n_listings,
12 AS window_months,
'per_rooms'::text AS basis
FROM deal_side d
JOIN ask_side a USING (rooms_bucket)
WHERE d.n_deals >= 30
AND a.n_listings >= 30
AND a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL
AND d.sold_median > 0
),
deal_global AS (
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND region_code = CAST(:region_code AS int)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
ask_global AS (
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM listings
WHERE is_active
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS int)
),
global_row AS (
SELECT
-1 AS rooms_bucket,
''::text AS district,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals::int AS n_deals,
a.n_listings::int AS n_listings,
12 AS window_months,
'global_fallback'::text AS basis
FROM deal_global d
CROSS JOIN ask_global a
WHERE a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL
AND d.sold_median > 0
)
INSERT INTO asking_to_sold_ratios (
rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, region_code
)
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM global_row
UNION ALL
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket
"""
)
# ── Post-insert counters ──────────────────────────────────────────────────────
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по
# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные.
_COUNTERS_SQL = text(
"""
SELECT
@ -292,13 +435,22 @@ _COUNTERS_SQL = text(
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"""Пересчитать asking_to_sold_ratios (TRUE-MIRROR refresh, #648 Stage 4).
"""Пересчитать asking_to_sold_ratios per region (#3512, TRUE-MIRROR refresh #648 Stage 4).
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
В ОДНОЙ транзакции (атомарно таблица никогда не пуста mid-refresh):
1. DELETE FROM asking_to_sold_ratios WHERE district = '' снести stale-строки.
2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1).
Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080.
В ОДНОЙ транзакции (атомарно таблица никогда не пуста mid-refresh для уже
посчитанных регионов):
1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') 080-derivation
INSERT...SELECT (byte-for-byte прежняя логика городская квота ЕКБ, #C2/#2583).
2. Каждый прочий регион реестра app.services.regions (минус 66): тот же
DELETE/INSERT цикл, но derivation скоупится по `region_code` симметрично
на sold- и asking-стороне (без городской квоты она не применима вне
ЕКБ, см. комментарий у generic-derivation SQL ниже).
Регион без своих ДКП-сделок ИЛИ без активных listings просто не получает строк
(global_row/per_bucket WHERE-гарды отфильтровывают NULL-медианы) это НЕ
ошибка, а честный признак «данных пока недостаточно», ловится потребителем
(estimator._get_asking_sold_ratio) через отсутствие строки явная деградация.
Затем ОДИН общий counters-запрос по всей таблице, commit, mark_done.
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
@ -323,7 +475,8 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
}
try:
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
# таблица не должна остаться пустой, если INSERT упадёт).
# таблица не должна остаться пустой, если INSERT упадёт). Регион 66 —
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
db.execute(_DELETE_SQL)
db.execute(
_REDERIVE_SQL,
@ -334,6 +487,17 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"fresh_days": LISTINGS_FRESH_DAYS,
},
)
for region_code in _OTHER_REGION_CODES:
db.execute(_DELETE_SQL_REGION, {"region_code": region_code})
db.execute(
_REDERIVE_SQL_REGION,
{
"region_code": region_code,
"ppm2_min": _PPM2_MIN,
"ppm2_max": settings.asking_ratio_ppm2_max,
"fresh_days": LISTINGS_FRESH_DAYS,
},
)
row = db.execute(_COUNTERS_SQL).mappings().first()
if row is not None:

View file

@ -806,7 +806,7 @@ async def run_avito_detail_backfill(
# и mark_failed с текстом про пул, как у домклика после #3283.
if _caused_by_empty_pool(e):
counters.failed += 1
logger.error(
logger.warning(
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
run_id,
@ -913,7 +913,7 @@ async def run_avito_detail_backfill(
# ratio: прогон 5210 (14 блоков из 20, ровно порог) отпечатал
# "ABORT -- 1 consecutive blocks" -- текущая серия в тот момент
# действительно равнялась единице, но обрыв был не по ней.
logger.error(
logger.warning(
"avito_detail_backfill: run_id=%d ABORT -- %s, "
"частая причина: %s. enriched=%d attempted=%d",
run_id,

View file

@ -256,7 +256,7 @@ async def backfill_cian_history(
if caused_by_no_proxy(exc):
result.no_proxy_stop = True
result.listings_failed_fetch += 1
logger.error(
logger.warning(
"cian_history_backfill: СТОП — пул прокси пуст, к площадке "
"не ходили. listing_id=%s processed=%d succeeded=%d",
listing_id,

View file

@ -493,7 +493,7 @@ async def run_domclick_detail_backfill(
# ниже. Причина не теряется: в записи прогона стоит
# no_proxy_stop=1 и mark_failed с текстом про пул.
counters.failed += 1
logger.error(
logger.warning(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
run_id,
@ -572,7 +572,7 @@ async def run_domclick_detail_backfill(
if consecutive_blocks >= max_consecutive_blocks:
# #3196: причину больше не выдумываем и не молчим — печатаем
# перепись диагнозов по HTTP-статусам этого прогона.
logger.error(
logger.warning(
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
"blocks, диагнозы: %s. enriched=%d attempted=%d",
run_id,

View file

@ -0,0 +1,105 @@
"""Фоновая пересылка GlitchTip-алерта в Telegram после отказа синхронной попытки.
Контекст (#3471, #3157, #3456). GlitchTip-вебхуки НЕ ретраятся — сам GlitchTip
безусловно помечает уведомление ``is_sent`` сразу после HTTP-ответа приёмника
(upstream-поведение, см. #3157), поэтому если синхронная пересылка в Telegram
(``app.api.v1.glitchtip``) не удалась, повторной доставки от GlitchTip не будет
никогда текст алерта исчезает бесследно. Ответ 502 на отказ Telegram остаётся
как есть (задуман осознанно, #3456: честный сигнал отправителю, а не тихий
проглот) меняется то, что происходит С ТЕКСТОМ алерта после этого отказа.
Почему не Celery. В tradein-mvp нет очереди с воркером: ни ``app/celery_app.py``,
ни зависимости ``celery`` в ``backend/pyproject.toml`` не существует (проверено
при работе над #3471) — попытка ``from celery import ...`` здесь упала бы
``ModuleNotFoundError``. Бутстрап полноценного Celery-воркера новый контейнер и
брокер, инфраструктурное решение вне границ этой задачи. Единственный доступный
внутри границ задачи (``app/api/v1/glitchtip.py`` + ``app/tasks/**``) механизм
«не блокировать интерактивный ответ, но не потерять текст» Starlette
``BackgroundTasks``: выполняется ПОСЛЕ отправки HTTP-ответа тем же процессом, вне
узкого интерактивного бюджета (``_INTERACTIVE_SEND_TIMEOUT_S=8s`` в glitchtip.py),
поэтому здесь можно позволить себе штатную "воркерную" ретрай-политику клиента
(``TelegramClient.send_message`` без явных ``timeout``/``max_retries`` 5 попыток,
backoff до 30s, см. ``app.services.tgbot.client``), плюс собственный внешний
потолок ниже.
Компромисс, честно: BackgroundTasks не переживает рестарт процесса (это не
персистентная очередь) если tradein-backend упадёт ровно между отказом
синхронной попытки и завершением фоновой, текст всё-таки потеряется. Событие
редкое (одно с начала эксплуатации, TRADE-IN-3F7, 28.08.2026), а сеть до Telegram
теряет отдельные запросы, а не рвётся на минуты (замер 12.09: 9/12 успешных
``getMe``) штатной ретрай-политики клиента обычно достаточно без внешнего
потолка вовсе. Персистентная очередь (переживающая рестарт) требует
Celery/Redis-воркера отдельное инфраструктурное решение.
Идемпотентность настолько, насколько дёшево. Текст между попытками не
пересобирается (переиспользуется уже отформатированный ``text`` из
``glitchtip.py`` никакого дублирования форматирования). Полной идемпотентности
нет и быть не может дёшево: Telegram ``sendMessage`` не идемпотентен сам по себе
(повтор создаёт НОВОЕ сообщение, не апдейтит старое) именно поэтому внешний
потолок попыток мал (``_MAX_ATTEMPTS``), а не «ретраить пока не получится».
"""
from __future__ import annotations
import asyncio
import logging
from app.services.tgbot.client import TelegramClient, TelegramError
logger = logging.getLogger(__name__)
__all__ = ["retry_forward_alert"]
# Внешний потолок ПОВЕРХ штатной ретрай-политики клиента (5 попыток внутри одного
# send_message с backoff до 30s) — защита от «недоставляемый алерт крутится в фоне
# вечно»: если сеть до Telegram не восстановилась за это время, сдаёмся и логируем
# ERROR с текстом, а не повторяем бесконечно.
_MAX_ATTEMPTS = 3
_RETRY_DELAY_S = 30.0
async def retry_forward_alert(
client: TelegramClient,
*,
chat_id: int,
text: str,
message_thread_id: int | None,
) -> None:
"""Досылает уже отформатированный текст алерта после отказа синхронной попытки.
``client`` ТОТ ЖЕ общий клиент приложения, что и в синхронном пути
(``get_telegram_client()`` в ``glitchtip.py``), а не новый инстанс: он живёт в
lifespan ради keep-alive-соединения (см. docstring ``glitchtip.py``).
"""
for attempt in range(1, _MAX_ATTEMPTS + 1):
try:
await client.send_message(
chat_id=chat_id,
text=text,
message_thread_id=message_thread_id,
# Без явных timeout/max_retries — штатная "воркерная" политика
# клиента (см. докстринг модуля).
)
except TelegramError:
if attempt == _MAX_ATTEMPTS:
logger.error(
"glitchtip alert retry: не удалось доставить алерт в Telegram "
"после %d попыток — текст потерян: %r",
_MAX_ATTEMPTS,
text[:200],
exc_info=True,
)
return
logger.warning(
"glitchtip alert retry: попытка %d/%d не удалась, повтор через %.0fs",
attempt,
_MAX_ATTEMPTS,
_RETRY_DELAY_S,
exc_info=True,
)
await asyncio.sleep(_RETRY_DELAY_S)
else:
logger.info(
"glitchtip alert retry: доставлено фоном с попытки %d/%d", attempt, _MAX_ATTEMPTS
)
return

View file

@ -9,26 +9,36 @@
ПРАВИЛО ОТБОРА ЯВНО И БЕЗ ПОДГОНКИ
------------------------------------
Отбираем N строк ключом::
Витрина показывает ПОЛОСУ РАСХОЖДЕНИЯ, а не всю сверку. С 2026-09-12 решением
владельца продукта на витрину попадают только сделки, у которых расхождение
прогноза с ценой ДКП лежит в пределах `BAND_MIN_ERR_PCT`..`BAND_MAX_ERR_PCT`
(5 %..+20 % включительно). Оставшиеся `limit` строк ранжируются ключом::
(полнота данных , свежесть квартала , id сделки )
Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой
ошибке превращает витрину в рекламу: показанные 20 строк перестают быть
выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель
читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда
код формально работает, а продукт врёт. Проверяется тестом
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
ЭТО ОТБОР ПОКАЗАТЕЛЬНЫХ СТРОК, И НАЗЫВАТЬ ЕГО НАДО ТАК. До 2026-09-12 здесь
не было ни фильтра, ни слагаемого ошибки в ключе, и подпись витрины это прямо
утверждала. Теперь утверждать это нельзя: строки с промахом крупнее полосы в
данных есть (на проде 30.08.2026 из показанных двадцати вне полосы было
двенадцать от 27,9 % до +75,7 %), и они не показываются. Поэтому полоса
названа в `REJECTION_RULE`, которое едет на фронт вместе со счётчиками
прогона, и в подписи под таблицей рядом с медианой расхождения ПО ВСЕЙ
СВЕРКЕ: два числа рядом не дают прочитать двадцать отобранных строк как
«вот так МЕРА обычно и попадает».
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
ключе и отбор по ошибке в фильтре одно и то же действие, и второе даже
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
больше 40% это почти всегда занижение ДКП ради налога» не держится: см.
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) «число некрасивое» причиной не является.
ЧТО ЭТО НЕ ОТМЕНЯЕТ. Внутри полосы отбор по величине ошибки по-прежнему
запрещён иначе витрина показывала бы лучший хвост уже самой полосы
(`test_selection_ignores_error_magnitude`). Счётчики прогона считаются ДО
полосы: `eligible` сколько строк прогон вообще собрал, `written` сколько
из них прошло полосу и поместилось в `limit`. Разница между ними видна
посетителю, и она честная ровно потому, что рядом сказано, чем именно
отобраны показанные. Если в полосу попало меньше `limit` строк показываем
сколько есть; добирать соседями по ошибке нельзя, это вернуло бы отбор по
величине ошибки в обход полосы.
Отбраковка по «данных нет» (нет прогноза, нет квартала, нет площади) осталась
прежней и живёт в `build_row`: строка вне полосы ОСТАЁТСЯ кандидатом и
попадает в счётчик `eligible`, её снимает отбор, а не отбраковка.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность,
схема улицы), у строки заполнено. Свежесть порядок квартала сделки.
@ -57,8 +67,10 @@
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
всё, что после этого дало большую ошибку, работа оценщика, и витрина
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
всё, что после этого дало большую ошибку, работа оценщика. С 2026-09-12
такая строка на витрину не выходит (полоса), но остаётся в `eligible` и
в подписи названа отобранной, а не несуществующей. Своей копии диапазона
здесь нет намеренно: прежние
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k из трёх
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
@ -75,7 +87,10 @@
ошибки, правило отбора выше не нарушено.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
сделок рассмотрено, сколько строк прогон собрал и по какому правилу из
них отобраны показанные. С появлением полосы это перестало быть
страховкой и стало обязательным: без счётчиков и правила отобранная
двадцатка читается как вся сверка. Поэтому итог
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
ручкой `/api/public/mera/showcase` вместе со строками.
@ -104,18 +119,47 @@ from app.services.street_scheme import StreetIndex, build_street_scheme, load_st
logger = logging.getLogger(__name__)
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
# ── Полоса расхождения: что показываем и что об этом сказано ─────────────────
#
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
# Границы ВКЛЮЧИТЕЛЬНЫЕ. Полоса несимметрична намеренно: решение владельца от
# 2026-09-12 — показывать сделки, где МЕРА не занизила больше чем на 5 % и не
# завысила больше чем на 20 %.
BAND_MIN_ERR_PCT = -5.0
BAND_MAX_ERR_PCT = 20.0
# Подпись полосы ВЫВОДИТСЯ из границ, а не вписывается рядом: «5 %…+20 %» в
# тексте и `>= -5.0` в коде — две независимые величины, и разъедутся они
# ровно тогда, когда порог однажды подвинут.
BAND_LABEL = f"от {BAND_MIN_ERR_PCT:+.0f} % до {BAND_MAX_ERR_PCT:+.0f} % включительно"
def in_band(err_pct: float) -> bool:
"""Попадает ли расхождение в показываемую полосу (границы включительно)."""
return BAND_MIN_ERR_PCT <= err_pct <= BAND_MAX_ERR_PCT
# ── Правило отбора и отбраковки: одна формулировка, она же едет на фронт ──────
#
# Санитарный диапазон ₽/м² применён выше по потоку, в `_load_sample`;
# дублировать его тут значило бы завести проверку, которая в проде не
# срабатывает никогда.
#
# ТЕКСТ ОБЯЗАН НАЗЫВАТЬ ПОЛОСУ. Пока фильтра не было, здесь стояло «величина
# отклонения на отбор и отбраковку не влияет — иначе витрина показывала бы
# лучший хвост, а не работу расчёта». С фильтром эта фраза стала ложью ровно
# про то, чего опасалась, поэтому она снята, а не смягчена.
REJECTION_RULE = (
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
"диапазон цены сделки (30 000600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
f"На витрине — ОТОБРАННАЯ полоса расхождения, а не вся сверка: показаны "
f"только сделки, у которых расхождение прогноза с ценой ДКП лежит {BAND_LABEL}. "
"Промахи крупнее полосы в данных есть, и здесь их не видно — судить по этим "
"строкам о точности расчёта нельзя, для этого есть медиана расхождения по "
"всей сверке. Внутри полосы порядок задают полнота данных и свежесть "
"квартала: величина отклонения на него не влияет, лучший хвост самой полосы "
"витрина тоже не показывает. Кроме полосы строку снимает только отсутствие "
"данных: расчёт МЕРЫ не дал ожидаемой цены продажи (мало аналогов), "
"неизвестен квартал сделки или площадь. Санитарный диапазон цены сделки "
"(30 000600 000 ₽/м² для Екатеринбурга) применён к выборке до расчёта, по "
"цене самой сделки."
)
NOTE = (
@ -184,7 +228,7 @@ def completeness(row: ShowcaseRow) -> int:
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
"""Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля."""
"""Ключ ранжирования. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге."""
return (
-completeness(row),
-(row.deal_date or date.min).toordinal(),
@ -193,8 +237,25 @@ def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
"""Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки)."""
return sorted(rows, key=_sort_key)[:limit]
"""Строки полосы 5 %..+20 %, до `limit` штук, по полноте и свежести.
ДВА ДЕЙСТВИЯ, И ОНИ РАЗНЫЕ. Сначала ФИЛЬТР по величине расхождения
(`in_band`) это и есть «витрина показывает отобранную полосу, а не всю
сверку», названное так же в `REJECTION_RULE` и в подписи под таблицей.
Потом РАНЖИРОВАНИЕ уцелевших по полноте данных и свежести квартала
внутри полосы величина ошибки на порядок не влияет, иначе показывался бы
лучший хвост уже самой полосы.
Фильтр стоит ЗДЕСЬ, а не в `build_row`, намеренно: строка вне полосы
обязана остаться кандидатом и попасть в счётчик `eligible`. Отбраковав её
раньше, мы получили бы «показано 20 из 20 годных» счётчик, из которого
отбор не виден вообще.
В полосе меньше `limit` строк возвращаем сколько есть. Добирать
ближайшими по ошибке нельзя: это тот же отбор по величине ошибки, просто
с другой стороны.
"""
return sorted((r for r in rows if in_band(r.err_pct)), key=_sort_key)[:limit]
def build_row(
@ -217,8 +278,11 @@ def build_row(
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все «данных нет»: спайн не дал ожидаемой
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
(делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
(делить не на что). Величина отклонения причиной отказа НЕ является ни при
каких значениях: строка с любым промахом становится кандидатом и попадает
в счётчик `eligible`. Полоса, по которой из кандидатов отбираются
показанные, применяется позже и в другом месте `select_rows`; здесь её
нет намеренно, иначе отбор перестал бы быть виден в счётчиках.
ФАКТ ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
@ -384,9 +448,14 @@ def refresh_landing_showcase_deals(
priced из них оценщик дал ожидаемую цену продажи
no_prediction не дал (мало аналогов / спайн упал)
incomplete цена есть, но нет квартала/площади строку не собрать
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
written из них показано (обрезано по `limit`)
eligible строк СОБРАНО всего, ДО полосы (данных хватило)
written из них показано: прошли полосу и поместились в `limit`
with_district у скольких показанных удалось определить район
`eligible` минус `written` это НЕ «столько не поместилось»: с 2026-09-12
в разницу входят и строки вне полосы 5 %..+20 %. Поэтому подпись под
таблицей называет `eligible` собранными строками, а чем отобраны
показанные говорит `REJECTION_RULE`, который едет тем же ответом.
"""
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
# его зависимостями, а web-процессу это на импорте приложения не нужно.
@ -444,6 +513,16 @@ def refresh_landing_showcase_deals(
candidates.append(row)
chosen = select_rows(candidates, limit)
# Сколько собранных строк вообще попало в полосу — в лог, а не в счётчики:
# колонки под него в `landing_showcase_runs` нет, а без него по `written`
# не отличить «полоса оставила мало» от «упёрлись в limit».
logger.info(
"в полосе %s: %d из %d собранных, показано %d",
BAND_LABEL,
sum(1 for r in candidates if in_band(r.err_pct)),
len(candidates),
len(chosen),
)
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
db.execute(_DELETE_SQL)
@ -488,7 +567,7 @@ def refresh_landing_showcase_deals(
logger.info(
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
"годных=%d записано=%d с_районом=%d",
"собрано=%d записано=%d с_районом=%d",
counters["considered"],
counters["priced"],
counters["no_prediction"],

View file

@ -1,22 +1,34 @@
"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`.
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`.
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся. Замер по проду (60 464 карточки): с округом 35 551, ВСЕ внутри
bbox региона 77; без округа внутри bbox 17 576 (это Московская область, регион
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» артефакт
парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва».
Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox 17 576 (это
Московская область, регион 50); без округа вне bbox 7 337. Отдельно 212
карточек с адресом вида «Екатеринбург (Cian)» артефакт парсера, считаются
своим счётчиком, чтобы не растворяться в «не целевой регион».
Область (регион 50) у Циана в адресе НЕ видна вовсе берём по ПОДДОМЕНУ
`source_url` (`https://<sub>.cian.ru/...`): `sub != "www"` область. Замер по
`msk_raw.cian_latest` 12.09.2026: `www` 38 030 карточек, из них 36 569 с
префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065,
balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306,
mytishchi 1288, podolsk 756, odintsovo 738, ) 0 карточек с префиксом округа,
итого не-www 24 784. Поддомен и префикс округа нигде не противоречат друг
другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область
на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`)
карточка НЕ область (консервативно, счётчик «не целевой регион»).
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр.
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр
для ЛЮБОГО целевого региона.
Два сигнала, и оба нужны ни один по отдельности не годится.
@ -42,16 +54,16 @@ bbox региона 77; без округа внутри bbox — 17 576 (это
qc_geo=0 у 97% найденных.
Что куда едет:
* регион 77 в `listings`, С координатами (geom есть сразу, radius-подбор
аналогов работает без ожидания `geocode_missing`);
* регион 50 НЕ пишется, ждёт появления региона 50 в реестре; лежит не в
воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) когда
регион появится, прогон по этой полке уже не потребует внешних вызовов;
* регион == `--region` в `listings`, С координатами (geom есть сразу,
radius-подбор аналогов работает без ожидания `geocode_missing`);
* адрес разрешился, но регион ответа не совпал с `--region` не пишется,
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` прогон с другим
`--region` подхватит её из кэша без единого внешнего вызова;
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33»)
свой счётчик, карточка не пишется.
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает.
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
@ -71,10 +83,43 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы.
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА
РАЗНЫХ прогона сборщика с разными GUID: московский (батч
`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч
`mo-serp-domclick-20260912`) оба пишут в один и тот же
`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью
`msk_raw.domclick_latest` отдаёт обе партии вперемешку курсор по `id` не
различает, из какого прогона строка. Поэтому фильтр по адресу здесь не
опциональная вторая линия, а единственный способ развести регионы.
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика
12.09.2026: московские карточки «Москва, Генерала Дорохова проспект, 49» и
подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса;
областные карточки «Московская область, Химки, 7-й м-н, проспект
Мельникова, 33», «Московская область, Одинцовский городской округ,
Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у
всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и
симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая
Москва приходит как «Москва, пос. Птичное, » посёлок стоит вторым
компонентом, первый по-прежнему «Москва», регион 77 не ломается. Координаты
заполнены у 100% карточек в обоих прогонах.
Запуск:
python -m app.tasks.msk_raw_import --dry-run
python -m app.tasks.msk_raw_import --limit 500
python -m app.tasks.msk_raw_import --source yandex
python -m app.tasks.msk_raw_import --source yandex --region 50
python -m app.tasks.msk_raw_import --source cian --region 50
python -m app.tasks.msk_raw_import --source domclick
python -m app.tasks.msk_raw_import --source domclick --region 50
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
python -m app.tasks.msk_raw_import --source avito --geocode --region 50
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
"""
@ -84,6 +129,7 @@ import argparse
import asyncio
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass
from urllib.parse import urlsplit
@ -95,15 +141,18 @@ from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services import dadata
from app.services.geocoder import normalize_address
from app.services.regions import REGIONS, is_within_bbox
from app.services.scraper_adapters import RealMatcherAdapter
logger = logging.getLogger(__name__)
MOSCOW_REGION_CODE = 77
MOSCOW_CITY = "Москва"
# Регион 50 (Московская область) в реестре `app.services.regions` ещё не заведён —
# карточки области не пишутся, а откладываются (см. докстринг модуля).
# Московская область в реестре `app.services.regions` заведена, но своего
# единого города у неё нет (`canonical_city is None`) — в listings.city для
# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит).
OBLAST_REGION_CODE = 50
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
DEFAULT_BATCH_SIZE = 500
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
@ -152,6 +201,8 @@ _ARTIFACT_RE = re.compile(r"Екатеринбург", re.IGNORECASE)
SOURCE_VIEWS = {
"cian": "msk_raw.cian_latest",
"avito": "msk_raw.avito_latest",
"yandex": "msk_raw.yandex_latest",
"domclick": "msk_raw.domclick_latest",
}
_PAGE_SQL = """
@ -407,14 +458,16 @@ class ImportCounters:
read: int = 0
skipped_artifact: int = 0
skipped_not_moscow: int = 0
# Карточка сама говорит про другой регион (префикс округа / поддомен
# Циана / компонент адреса не совпал с целевым `--region`).
skipped_not_target_region: int = 0
skipped_invalid: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область —
# адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не
# разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск
# этого счётчика читается как «проверь квоту», а не «в Москве стало меньше
# домов».
skipped_oblast: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
# не «в целевом регионе стало меньше домов».
skipped_geo_other_region: int = 0
skipped_ungeocoded: int = 0
selected: int = 0
inserted: int = 0
@ -441,8 +494,8 @@ class ImportCounters:
self.read
== self.selected
+ self.skipped_artifact
+ self.skipped_not_moscow
+ self.skipped_oblast
+ self.skipped_not_target_region
+ self.skipped_geo_other_region
+ self.skipped_ungeocoded
+ self.skipped_invalid
)
@ -460,6 +513,150 @@ def is_moscow_address(address: str | None) -> bool:
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
def cian_subdomain(payload: dict) -> str | None:
"""Поддомен `source_url` Циана: `https://<sub>.cian.ru/...` → `<sub>`.
Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому
область читается из URL. Поддомен и префикс округа не противоречат друг
другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026):
`www` 38 030 карточек (36 569 с префиксом округа, это Москва); все
прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod,
khimki, mytishchi, podolsk, odintsovo, ) 0 карточек с префиксом округа.
Хост не `*.cian.ru` или `source_url` отсутствует None (консервативно:
региону не сопоставляем).
"""
url = payload.get("source_url")
if not isinstance(url, str) or not url:
return None
# `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем
# регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо
# суффикса и уехал в «не целевой регион».
host = urlsplit(url).hostname or ""
if not host.endswith(".cian.ru"):
return None
sub = host[: -len(".cian.ru")]
return sub or None
def is_cian_oblast_payload(payload: dict) -> bool:
"""Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`)."""
sub = cian_subdomain(payload)
return sub is not None and sub != "www"
def is_moscow_yandex_address(address: str | None) -> bool:
"""У Яндекса регион — второй компонент полного адреса.
Адрес приходит нормализованным и с городом: «Россия, Москва, Коробейников
переулок, 1». Замер по 21 393 карточкам первого прохода: во втором
компоненте ровно ДВА значения «Москва» 10 610 и «Московская область»
10 783, третьего не встречается. Поэтому ни префикса округа (как у Циана),
ни внешнего геокода (как у Авито) источнику не нужно: разделение 77 и 50
читается из самой карточки и стоит ноль вызовов.
Новая Москва отдельным значением НЕ приходит Троицк и Зеленоград Яндекс
кладёт под «Москва», что совпадает с кодом региона 77.
"""
parts = [part.strip() for part in (address or "").split(",")]
return len(parts) > 1 and parts[1] == "Москва"
def is_oblast_yandex_address(address: str | None) -> bool:
"""Регион 50 у Яндекса: второй компонент адреса — «Московская область».
Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются
ровно два значения (см. докстринг модуля), третьего нет среди карточек
Яндекса «не Москва» и означает «область».
"""
parts = [part.strip() for part in (address or "").split(",")]
return len(parts) > 1 and parts[1] == "Московская область"
def is_moscow_domclick_address(address: str | None) -> bool:
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
Источник собран запросом с GUID-ом Москвы в параметре address и дополнительно
отфильтрован по bbox на стороне сборщика, так что областных карточек в сырье
и не должно быть. Фильтр здесь вторая линия: сменится GUID в сборщике или
появится второй город в той же таблице импорт не потащит его в Москву
молча. Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно
одно значение, «Москва», областных нет ни одной.
Новая Москва отдельным значением НЕ приходит: «Москва, x. Ильичевка, »,
«Москва, пос. Птичное, » посёлок стоит ВТОРЫМ компонентом, первый всегда
город, что совпадает с кодом региона 77.
"""
parts = [part.strip() for part in (address or "").split(",")]
return bool(parts) and parts[0] == "Москва"
def is_oblast_domclick_address(address: str | None) -> bool:
"""Регион 50 у ДомКлика: первый компонент адреса — «Московская область».
Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards`
копит ДВА разных прогона сборщика (московский батч
`msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без
своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии
вперемешку фильтр по адресу обязателен, не опционален. Замер живьём на
API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект
Мельникова, 33», «Московская область, Одинцовский городской округ,
Звенигород, 3-й м-н, 28» и подобные первый компонент «Московская
область» у всех 140 карточек выборки с семи разных смещений выдачи.
"""
parts = [part.strip() for part in (address or "").split(",")]
return bool(parts) and parts[0] == "Московская область"
def _payload_point(payload: dict) -> tuple[float, float] | None:
"""(lat, lon) из сырья ДомКлика, если сборщик их положил и они читаются."""
try:
return float(payload["lat"]), float(payload["lon"])
except (KeyError, TypeError, ValueError):
return None
def is_oblast_domclick_payload(payload: dict) -> bool:
"""Область у ДомКлика: префикс адреса ИЛИ координата внутри bbox области.
Одного префикса мало. Замер на собранном корпусе 12.09.2026 (2 961 карточка
областного батча): «Московская область» стоит первым компонентом у 2 960, а
у одной «Можайский муниципальный округ, д. Семёновское, 1», 55.5116/35.8293.
Это настоящая область (Можайск), и строгий префикс выбросил бы её молча.
Московский батч тем же замером даёт «Москва» первым компонентом у ВСЕХ
22 836 карточек, поэтому явный отказ Москве идёт раньше гео-ветки и bbox
Москвы (вложенный в областной) не может протащить столичную карточку в 50.
"""
address = payload.get("address")
if is_oblast_domclick_address(address):
return True
if is_moscow_domclick_address(address):
return False
point = _payload_point(payload)
if point is None:
return False
return is_within_bbox(point[0], point[1], REGIONS[OBLAST_REGION_CODE].bbox_region)
def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
"""Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
return lambda payload: fn(payload.get("address"))
# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только
# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него
# молчит. Ключа нет только для источника, который вообще не умеет отличать
# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар
# фильтр обязан быть в реестре явно.
REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = {
("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address),
("cian", OBLAST_REGION_CODE): is_cian_oblast_payload,
("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address),
("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address),
("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address),
("domclick", OBLAST_REGION_CODE): is_oblast_domclick_payload,
}
def recompute_listing_segment(payload: dict) -> str:
"""Заново считаем сегмент: 'novostroyki' только при продаже застройщиком.
@ -505,6 +702,7 @@ def import_msk_raw(
db: Session,
*,
source: str = "cian",
region: int = MOSCOW_REGION_CODE,
batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None,
dry_run: bool = False,
@ -513,29 +711,38 @@ def import_msk_raw(
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
) -> ImportCounters:
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
if region not in SUPPORTED_REGIONS:
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
view = SOURCE_VIEWS[source]
counters = ImportCounters()
matcher = RealMatcherAdapter()
budget = GeocodeBudget(remaining=max(0, geocode_limit))
filter_by_okrug = source == "cian"
if geocode and filter_by_okrug:
# У Циана город опознаётся префиксом округа даром и без ошибок —
# тратить на него внешнюю квоту незачем.
# Источник, который сам говорит про целевой регион: у Циана это префикс
# округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика —
# первый. Авито не говорит ничего ни для какого региона, ему нужен
# пред-геокод, поэтому в реестре его нет вовсе.
city_filter = REGION_FILTERS.get((source, region))
if geocode and city_filter is not None:
# Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
# незачем.
raise SystemExit(f"source={source}: --geocode нужен только для avito")
if not filter_by_okrug and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить Москву от области:
# ни города в адресе, ни координат. Пишем только по явному разрешению.
if city_filter is None and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
# прочих: ни города в адресе, ни координат. Пишем только по явному
# разрешению.
if not (dry_run or allow_unfiltered):
raise SystemExit(
f"source={source}: адрес не содержит признака города, Москву от "
"области не отличить. Нужен --geocode (штатный путь), "
f"source={source}: адрес не содержит признака города, регион "
f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
"--allow-unfiltered (аварийный) или --dry-run."
)
logger.warning(
"source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; "
"строки лягут без geom и вперемешку с областью",
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
"вовсе; строки лягут без geom и вперемешку с прочими регионами",
source,
region,
)
if geocode and not dry_run:
db.execute(text(_GEO_CACHE_DDL))
@ -551,16 +758,16 @@ def import_msk_raw(
if is_artifact_address(address):
counters.skipped_artifact += 1
continue
if filter_by_okrug and not is_moscow_address(address):
counters.skipped_not_moscow += 1
if city_filter is not None and not city_filter(payload):
counters.skipped_not_target_region += 1
continue
if geocode:
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
if point is None:
counters.skipped_ungeocoded += 1
continue
if point.region_code != MOSCOW_REGION_CODE:
counters.skipped_oblast += 1
if point.region_code != region:
counters.skipped_geo_other_region += 1
continue
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
# трогаем, пересбор корпуса от этого не зависит. geom появляется
@ -582,19 +789,26 @@ def import_msk_raw(
if dry_run or not lots:
continue
# У региона 50 своего единого города нет (`canonical_city is None` в
# реестре regions) — пишем city=None, `save_listings` его COALESCE'ит
# и существующее значение не затирает. Подбор аналогов не страдает:
# он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП
# ключуется на `deals.city`, а не на `listings.city`.
city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=MOSCOW_REGION_CODE,
city=MOSCOW_CITY,
region_code=region,
city=city,
)
counters.inserted += inserted
counters.updated += updated
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
logger.info(
"msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
"msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
source,
region,
counters.read,
counters.selected,
counters.written,
@ -612,11 +826,12 @@ def import_msk_raw(
geocode_limit,
)
logger.info(
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d "
"область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"геокод-вызовов=%d | сходится=%s",
source,
region,
" (dry-run)" if dry_run else "",
counters.read,
counters.selected,
@ -624,8 +839,8 @@ def import_msk_raw(
counters.inserted,
counters.updated,
counters.writer_skipped if not dry_run else 0,
counters.skipped_not_moscow,
counters.skipped_oblast,
counters.skipped_not_target_region,
counters.skipped_geo_other_region,
counters.skipped_ungeocoded,
counters.skipped_artifact,
counters.skipped_invalid,
@ -640,21 +855,30 @@ def main() -> None:
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)")
parser = argparse.ArgumentParser(
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
)
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
parser.add_argument(
"--region",
type=int,
choices=SUPPORTED_REGIONS,
default=MOSCOW_REGION_CODE,
help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область",
)
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
parser.add_argument(
"--allow-unfiltered",
action="store_true",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
)
parser.add_argument(
"--geocode",
action="store_true",
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
"в listings уходит только регион 77, область откладывается",
"в listings уходит только целевой регион (--region), прочее пропускается",
)
parser.add_argument(
"--geocode-limit",
@ -670,6 +894,7 @@ def main() -> None:
import_msk_raw(
db,
source=args.source,
region=args.region,
batch_size=args.batch_size,
limit=args.limit,
dry_run=args.dry_run,

View file

@ -427,7 +427,7 @@ async def run_yandex_detail_backfill(
)
if consecutive_blocks >= max_consecutive_blocks:
aborted_by_blocks = True
logger.error(
logger.warning(
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
"non-200 responses. enriched=%d attempted=%d",
run_id,

View file

@ -354,7 +354,7 @@ async def enrich_yandex_newbuilding_sweep(
if caused_by_no_proxy(exc):
result.no_proxy_stop = True
result.failed_resolve += 1
logger.error(
logger.warning(
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
"house_id=%s processed=%d succeeded=%d",
house_id,
@ -419,7 +419,7 @@ async def enrich_yandex_newbuilding_sweep(
if caused_by_no_proxy(exc): # #3197 — см. блок resolve выше
result.no_proxy_stop = True
result.failed_fetch += 1
logger.error(
logger.warning(
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
"house_id=%s processed=%d succeeded=%d",
house_id,

View file

@ -26,13 +26,12 @@ import logging
import os
import signal
from contextlib import suppress
from typing import Any
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
from app.services.tgbot.bridge import run_poll_loop
from app.services.tgbot.client import TelegramClient
from app.services.tgbot.client import TelegramClient, verify_chat_and_topic
logging.basicConfig(
level=logging.INFO,
@ -57,18 +56,20 @@ if settings.glitchtip_dsn:
import sentry_sdk
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.types import Event, Hint
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_pii_event,
)
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
"""Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) +
Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ
независимыми векторами, которые `include_local_variables=False` ниже и
этот хук закрывают вместе:
def _before_send(event: Event, hint: Hint) -> Event | None:
"""Композиция payments-disabled drop (#3471) + платёжный body-wipe (PR-D2) +
PII-scrub (form-данные) + Telegram bot-токен redaction (#tgsupport review).
Токен утекает ДВУМЯ независимыми векторами, которые
`include_local_variables=False` ниже и этот хук закрывают вместе:
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
locals (`self._base`/`url` в `TelegramClient._request`) в traceback
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
@ -78,18 +79,22 @@ if settings.glitchtip_dsn:
belt-and-suspenders на случай #1 (если include_local_variables
случайно вернут) И на span data.
Платёжный body-wipe belt-and-suspenders: этот процесс не держит ASGI-
приложения (нет `request` в event сегодня), но тот же обработчик передан
ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия
со всеми точками инициализации sentry_sdk в проекте (см. app/main.py).
Payments-disabled drop и платёжный body-wipe belt-and-suspenders: этот
процесс не держит ASGI-приложения (нет `request`/HTTPException в event
сегодня, реальный источник 503 app/main.py), но тот же обработчик
передан ОБОИМ каналам ниже (before_send/before_send_transaction) ради
единообразия со всеми точками инициализации sentry_sdk в проекте.
"""
scrubbed = scrub_payment_request_body(event, hint)
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint)
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint)
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -114,8 +119,34 @@ def _should_run() -> bool:
async def _run_bridge() -> None:
client = TelegramClient(settings.telegram_bot_token)
await run_poll_loop(client, SessionLocal)
# `async with` — чтобы пул keep-alive соединений закрывался при любом выходе
# из поллинга (кооперативный drain по SIGTERM, hard-cancel, исключение).
# Клиент один на весь процесс: пересоздание на запрос убивало keep-alive и
# заставляло каждый long-poll начинаться с TCP+TLS-хендшейка.
async with TelegramClient(
settings.telegram_bot_token,
relay_base_url=settings.telegram_relay_base_url,
relay_secret=settings.telegram_relay_secret,
# Бот-роль (review H2, #3471) — своя, меньшая доля общего бюджета
# группы; см. докстринг настройки в app.core.config.
group_rate_limit_per_minute=settings.telegram_group_rate_limit_bot_per_minute,
) as client:
# Startup-проверка (#3471): убеждаемся ОДИН раз, что чат/тема живы,
# прежде чем уходить в бесконечный poll loop. Не блокирует и не роняет
# запуск при неудаче — см. докстринг `verify_chat_and_topic`.
await verify_chat_and_topic(
client,
chat_id=settings.telegram_support_chat_id,
topic_id=settings.telegram_support_topic_id,
label="support",
)
await verify_chat_and_topic(
client,
chat_id=settings.telegram_alerts_chat_id,
topic_id=settings.telegram_alerts_topic_id,
label="alerts",
)
await run_poll_loop(client, SessionLocal)
async def _await_bridge(task: asyncio.Task[None]) -> None:

View file

@ -0,0 +1,219 @@
-- 300_sales_vs_listings_deals_rooms_drop.sql
-- Purpose: #3451 — TVF street_sales_vs_listings() фильтровала сделки по `d.rooms`,
-- а `deals.rooms` у источника 'rosreestr' — НЕ комнатность, а бакет площади.
-- Импортёр (tradein-mvp/deploy/import-rosreestr.sh) пишет туда
-- `CASE WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 62 THEN 2
-- WHEN area < 85 THEN 3 ELSE 4 END`
-- — прод: 321 559 строк из 321 560 удовлетворяют `rooms == area_bucket(area_m2)`,
-- max(rooms) = 4 (пятикомнатных в данных не бывает по построению). То есть предикат
-- работал ВТОРЫМ фильтром по площади и спорил с полосой ±tolerance, которую функция
-- считает сама: клиент 49 м² / 1к получал полосу 41.756.4 м², но `d.rooms = 1`
-- оставлял из неё только < 44 м².
--
-- Ровно эта патология снята в #3256 (PR #3445) на четырёх сделочных площадках
-- эстиматора; здесь — последний оставшийся потребитель.
--
-- КЛЮЧ АСИММЕТРИЧНЫЙ, копипастой из #3256 не чинится:
-- - `d.rooms = p_rooms` в window_deals — СНЯТ (синтетика из площади);
-- - `l.rooms = p_rooms` в window_listings — ОСТАЁТСЯ: у объявлений комнатность
-- настоящая (приходит с карточки), и это единственный признак ассортимента
-- на листинговой стороне.
--
-- Прод-замер (2026-09-12, БД tradein, 1160 реальных клиентских запросов из
-- trade_in_estimates; улица извлеклась у 954, у 206 — известная H1 «адрес вне
-- словаря», к этой правке отношения не имеет). Считалось тем же путём, что у
-- продукта: street/city резолвятся extract_street_name()/_resolve_target_city():
-- - непустой ответ /sales-vs-listings: 805 (84.4 %) → 899 (94.2 %), впервые
-- непустых 94 клиента;
-- - сделок в выборке суммарно: 68 147 → 88 816;
-- - из них с подобранным объявлением (то, что реально показывается парами):
-- 30 830 → 39 193;
-- - выборка не сократилась НИ У КОГО (0 из 954) — предикат умел только резать.
-- Прогноз из #3451 был «те же 180 клиентов»; измеренная величина — 94. Разница в
-- том, что оценка 180 бралась по коридору эстиматора (другие period/tolerance и
-- другой street-pattern), а не по этой витрине; в файл кладётся измеренное.
--
-- NULL здесь не появляется: ветка ELSE в CASE импортёра ловит и NULL-площадь
-- (все WHEN дают NULL → ELSE 4), прод подтверждает 0 NULL в deals.rooms при
-- source='rosreestr'. Поэтому `deal_rooms: int` в SalesListingPair остаётся
-- обязательным полем — контракт API не меняется.
--
-- Что НЕ меняется и почему:
-- - Сигнатура функции — те же 7 аргументов и те же типы, что после м.205/211.
-- CREATE OR REPLACE с ИЗМЕНЁННЫМ списком типов создал бы ВТОРУЮ перегрузку
-- вместо замены (грабли #2627) — здесь список побайтово тот же.
-- - `is_active` по-прежнему НЕ фильтруется (снятые объявления и есть материал
-- пейринга — объявление снимают ПОСЛЕ продажи), см. шапки 067/205/211.
-- - Сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно.
-- - Caller (app/api/v1/trade_in.py, /sales-vs-listings) не меняется.
--
-- ЗАВИСИМОСТИ: 211 (текущее тело + 7-арг сигнатура). Deploy order: после 299.
-- Идемпотентность: CREATE OR REPLACE + COMMENT ON — re-run safe.
BEGIN;
CREATE OR REPLACE FUNCTION street_sales_vs_listings(
p_street_pattern text,
p_area_m2 numeric,
p_rooms integer,
p_window_days integer DEFAULT 180,
p_area_tolerance numeric DEFAULT 0.15,
p_period_months integer DEFAULT 24,
p_target_city text DEFAULT NULL
)
RETURNS TABLE (
deal_id bigint,
deal_date date,
deal_price_rub bigint,
deal_price_per_m2 integer,
deal_area_m2 numeric,
deal_rooms integer,
deal_floor integer,
deal_address text,
listing_id bigint,
listing_source text,
listing_source_url text,
listing_date date,
listing_price_rub bigint,
listing_price_per_m2 integer,
listing_area_m2 numeric,
days_listing_to_deal integer,
discount_pct numeric
)
LANGUAGE sql
STABLE
AS $$
WITH window_deals AS (
-- Сделки в улице + период. Фильтр по area + (#2583 H4) city.
-- Предиката по d.rooms здесь НЕТ — #3256/#3451: deals.rooms у источника
-- 'rosreestr' не комнатность, а бакет площади (см. шапку файла).
SELECT
d.id AS deal_id,
d.deal_date AS deal_date,
d.price_rub AS deal_price_rub,
d.price_per_m2 AS deal_price_per_m2,
d.area_m2 AS deal_area_m2,
d.rooms AS deal_rooms,
d.floor AS deal_floor,
d.address AS deal_address
FROM deals d
WHERE d.source = 'rosreestr'
AND d.address ILIKE p_street_pattern
AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
AND p_area_m2 * (1.0 + p_area_tolerance)
AND d.deal_date > NOW() - (p_period_months || ' months')::interval
AND d.price_rub > 0
-- #2583 H4: deals.city заполнена на 100% — строгое равенство.
-- NULL p_target_city (город вне словаря) → фильтр не применяется.
AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city))
),
window_listings AS (
-- Кандидаты-listings на той же улице, rooms exact (у ОБЪЯВЛЕНИЙ комнатность
-- настоящая — предикат законен и остаётся, #3451), area ±tolerance,
-- (#2583 H4) тот же город что deals-сторона, (#2660) только вторичка.
SELECT
l.id AS listing_id,
l.source AS listing_source,
l.source_url AS listing_source_url,
l.listing_date AS listing_date,
l.price_rub AS listing_price_rub,
l.price_per_m2 AS listing_price_per_m2,
l.area_m2 AS listing_area_m2,
l.rooms AS listing_rooms,
COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date
FROM listings l
WHERE l.address ILIKE p_street_pattern
AND l.rooms = p_rooms
AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
AND p_area_m2 * (1.0 + p_area_tolerance)
AND l.price_rub > 0
AND COALESCE(l.listing_date, l.scraped_at::date)
> NOW() - ((p_period_months + 6) || ' months')::interval
-- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%,
-- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим"
-- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий
-- фильтр выбросил бы почти все listings кроме avito.
AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city))
-- #2660 novostroyki guard (#1186): к ДКП-сделке вторички нельзя
-- подставлять лот застройщика — девелоперский прайс не торгуется и
-- уводит показываемый «медианный торг». Прод: 27.3% кандидатов —
-- первичка. NULL = legacy вторичка до м.011, оставляем.
AND (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka')
),
paired AS (
-- LEFT JOIN: сохраняем все сделки даже если нет listing match.
-- Для каждой сделки выбираем listing с listing_date ближайший
-- к deal_date (предпочтительно перед сделкой).
SELECT DISTINCT ON (wd.deal_id)
wd.deal_id,
wd.deal_date,
wd.deal_price_rub,
wd.deal_price_per_m2,
wd.deal_area_m2,
wd.deal_rooms,
wd.deal_floor,
wd.deal_address,
wl.listing_id,
wl.listing_source,
wl.listing_source_url,
wl.listing_date,
wl.listing_price_rub,
wl.listing_price_per_m2,
wl.listing_area_m2,
(wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal,
CASE
WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0
THEN ROUND(
(wd.deal_price_rub - wl.listing_price_rub)::numeric
/ wl.listing_price_rub * 100,
2
)
ELSE NULL
END AS discount_pct
FROM window_deals wd
LEFT JOIN window_listings wl
ON wl.listing_event_date
BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date
AND (wd.deal_date + interval '30 days')::date
ORDER BY
wd.deal_id,
-- prefer listing event дата перед сделкой и ближе к ней
CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END,
CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END,
ABS((wd.deal_date - wl.listing_event_date))
)
SELECT
deal_id,
deal_date,
deal_price_rub,
deal_price_per_m2,
deal_area_m2,
deal_rooms,
deal_floor,
deal_address,
listing_id,
listing_source,
listing_source_url,
listing_date,
listing_price_rub,
listing_price_per_m2,
listing_area_m2,
days_listing_to_deal,
discount_pct
FROM paired
ORDER BY deal_date DESC;
$$;
COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS
'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, '
'city-filter #2583 H4 (миграция 205), segment-guard #2660/#1186 (миграция 211), '
'снятие предиката по d.rooms #3451/#3256 (миграция 300). '
'Per-street matching: address ILIKE, area ±tolerance, rooms exact ТОЛЬКО на '
'listings-стороне (deals.rooms у rosreestr — синтетика из площади), window_days '
'до даты сделки (+30д grace), city-scope (p_target_city, deals строго / listings '
'терпимо к NULL), listings — только вторичка (listing_segment IS NULL или '
'vtorichka). Возвращает LEFT JOIN — сделки без listing match имеют '
'listing_* = NULL. discount_pct = (deal - listing) / listing * 100. '
'is_active намеренно НЕ фильтруется: снятые объявления и есть материал пейринга.';
COMMIT;

View file

@ -0,0 +1,59 @@
-- 301_web_support_message_idempotency_key.sql
-- Идемпотентность отправки веб-сообщения в поддержку (#3471 retry-storm):
-- канал Selectel -> api.telegram.org теряет заметную долю коротких запросов
-- (см. app/api/v1/support.py, _INTERACTIVE_SEND_TIMEOUT_S), поэтому повтор
-- клиента (fetch-ретрай/двойной клик/переотправка по таймауту) — обычное
-- дело. Раньше повтор создавал ВТОРУЮ строку в web_support_messages и ВТОРОЕ
-- зеркало в support-топике.
--
-- idempotency_key — client-provided (заголовок Idempotency-Key) ИЛИ
-- детерминированный fallback-отпечаток sha256(identity|текст|минутное окно)
-- для клиентов без заголовка (app/api/v1/support.py::_resolve_idempotency_key).
-- Всегда opaque-строка (клиентский токен или hex-хэш) — ТЕЛО СООБЩЕНИЯ сюда
-- никогда не попадает в открытом виде, только его отпечаток.
--
-- Scoped к (thread_id, direction='in'): идемпотентность осмысленна только для
-- inbound (клиентских) сообщений — direction='out' (ответ оператора) её не
-- требует, там уже есть своя partial-уникальность на topic_message_id (187).
-- thread_id, а не username/anon-token: таблица не хранит identity напрямую,
-- а тред уже гарантированно создан (`get_or_create_thread`) к моменту INSERT
-- (см. H1 в app/api/v1/support.py — тред создаётся ДО record_inbound).
--
-- Гонку двух одновременных запросов с одним ключом закрывает САМ уникальный
-- индекс + `INSERT ... ON CONFLICT DO NOTHING` в
-- web_support_storage.record_inbound (не read-then-write) — на конфликте
-- вызывающая сторона дочитывает уже вставленную строку и возвращает её тем
-- же ответом (тот же id), а не создаёт вторую строку.
--
-- IDEMPOTENCY: ADD COLUMN/CREATE INDEX IF NOT EXISTS — безопасный re-run.
-- Зависимости: 187_web_support_chat.sql (таблица web_support_messages).
BEGIN;
-- Блокирующий DDL не должен ждать чужую сессию бесконечно: без этого
-- ALTER встаёт в очередь за долгим запросом и уводит за собой ВСЕ
-- последующие обращения к таблице (#2752). Пять секунд — не успел взять
-- лок, деплой падает честно, а прод продолжает работать.
SET LOCAL lock_timeout = '5s';
ALTER TABLE web_support_messages
ADD COLUMN IF NOT EXISTS idempotency_key text;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'web_support_messages_idempotency_key_len_chk'
) THEN
ALTER TABLE web_support_messages
ADD CONSTRAINT web_support_messages_idempotency_key_len_chk
CHECK (idempotency_key IS NULL OR char_length(idempotency_key) BETWEEN 1 AND 160);
END IF;
END $$;
COMMENT ON COLUMN web_support_messages.idempotency_key IS 'Ключ идемпотентности inbound-отправки (#3471) — client-provided заголовок Idempotency-Key ("client:...") ИЛИ sha256-отпечаток thread+текст+минутное окно ("auto:...") для клиентов без заголовка. NULL для direction=''out'' и для строк до этой миграции.';
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_thread_idempotency_uq
ON web_support_messages (thread_id, idempotency_key)
WHERE idempotency_key IS NOT NULL AND direction = 'in';
COMMIT;

View file

@ -0,0 +1,52 @@
-- 302_scrape_schedules_seed_rosreestr_dkp_50.sql
-- Seed-строка scrape_schedules для региона 50 (Московская область) — #3051, трек МО.
--
-- Dependencies: 289_rosreestr_fdw_msk_columns_seed77.sql (та же таблица, тот же
-- формат source/default_params).
-- Apply after: 301_web_support_message_idempotency_key.sql
--
-- WHY:
-- Код-часть импорта уже параметризована регионом (`scheduler.py::_job_rosreestr_dkp`
-- читает `region_code` из default_params и валидирует его по `app.services.regions`),
-- а wildcard-хендлер `rosreestr_dkp_import_*` резолвит любое имя с суффиксом кода.
-- Регион 50 заведён в реестре и уже на проде, поэтому включение области стоит ровно
-- одной строки расписания — новой логики не требуется.
--
-- У региона 50 `canonical_city IS NULL`, то есть он идёт по ветке региона 66:
-- `deals.city` берётся из источника (муниципалитет — Балашиха, Химки, Подольск),
-- `raw_payload` не заполняется, строки с пустым city отбрасываются. Для области это
-- и есть верное поведение: единого города у региона нет, подставлять нечего.
--
-- Замер по FDW (2026-09-12, прод): под полным WHERE импорта регион 50 даёт
-- 113 351 сделку ДКП с 2024-01-01 — корпус того же порядка, что московский.
--
-- Строка ВЫКЛЮЧЕНА (enabled=false) — ровно как seed 77 в миграции 289: миграция
-- заводит расписание, включение и первый прогон остаются отдельным решением
-- main-сессии. Окно 4-6 UTC совпадает с окнами регионов 66 и 77; прогоны
-- сериализуются планировщиком, а первая полная заливка 77 заняла 3 минуты на
-- 212 937 строк, так что третий регион в то же окно помещается с запасом.
--
-- ИДЕМПОТЕНТНОСТЬ: ON CONFLICT (source) DO NOTHING — повторный прогон no-op и,
-- что важнее, НЕ сбрасывает enabled обратно в false после ручного включения.
BEGIN;
SET LOCAL lock_timeout = '5s';
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
default_params
)
VALUES (
'rosreestr_dkp_import_50',
false,
4,
6,
'{"region_code": 50, "since": "2024-01-01", "batch_size": 2000}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,79 @@
-- 303_scrape_schedules_seed_landing_showcase_deals.sql
-- Расписание для пересчёта витрины сделок публичного лэндинга (issue #3469).
--
-- ЧТО БЫЛО. Задача `landing_showcase_deals` (миграции 276/277, таблицы
-- landing_showcase_deals + landing_showcase_runs) в scrape_schedules НЕ СТОЯЛА:
-- `SELECT * FROM scrape_schedules WHERE source LIKE '%showcase%'` — 0 строк
-- (замер на проде 12.09.2026). Пересчёт был ручным шагом, и за всё время его
-- запускали четырежды; на 12.09 лэндинг показывал прогон от 30.08 — тринадцать
-- суток. Handler в реестре тоже отсутствовал, то есть строка расписания без
-- него не помогла бы: обе половины регистрации задачи (Handler в
-- app/services/product_handlers.py + вот эта строка) едут одним PR.
--
-- ТАКТ — СУТКИ, И СЧИТАЕТСЯ ОН НЕ ОТ ДАННЫХ, А ОТ КОДА.
-- Вход витрины — ДКП-сделки Росреестра, они приезжают ПОКВАРТАЛЬНО, и по
-- входу хватило бы такта в квартал. Но витрина показывает не сделки, а
-- РАСХОЖДЕНИЕ прогноза МЕРЫ с ценой сделки, а прогноз пересчитывается тем же
-- спайном оценщика, что и боевой расчёт: любая правка оценщика, коэффициентов
-- СберИндекса, набора активных объявлений или правила отбора (миграция 276,
-- полоса 5..+20 % от 12.09.2026) меняет ЧИСЛА на странице, не трогая ни одной
-- сделки. Деплой у продукта чаще, чем квартал, — поэтому такт суточный: столько
-- живёт окно «код уже другой, а витрина ещё прежняя». Прогон дешёвый и без
-- внешних вызовов (200 сделок через спайн + запись 20 строк, ~минуты CPU
-- ночью), так что цена суточного такта — та же, что у соседнего
-- landing_stats_refresh (миграция 275).
--
-- ЭТА ЖЕ СТРОКА ЗАВОДИТ ВИТРИНУ В МОНИТОР СВЕЖЕСТИ. Сводка просроченных
-- источников (`emit_stale_digest`, scraper_kit/orchestration/scheduler.py,
-- #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и бьёт тревогу (logger.error →
-- GlitchTip), когда источник не приносил данных дольше
-- STALE_DIGEST_INTERVAL_FACTOR × его такта — то есть здесь дольше ТРЁХ СУТОК.
-- Отдельного монитора для витрины не заводится намеренно: её молчание было
-- невидимо ровно потому, что источника не существовало для сводки, а не потому,
-- что сводка не умеет про него говорить (живой пример с прода 12.09.2026:
-- «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep
-- 3.5d/1d»). interval_days в default_params стоит ЯВНО — им же сводка считает
-- порог (`_schedule_interval_days`), и умолчание «1» лучше не подразумевать.
--
-- ОКНО 06:0007:00 UTC (11:0012:00 по Екатеринбургу): после импорта сделок
-- Росреестра (rosreestr_dkp_import, окно 0406) и после landing_stats_refresh
-- (0506) — витрина считается по уже обновлённым за ночь данным; и за два часа
-- до deals_freshness_monitor (0809), так что утренний пересчёт успевает
-- сняться с просрочки до утренней же проверки.
--
-- enabled=true — как у landing_stats_refresh: задача только читает базу и
-- перезаписывает две свои маленькие таблицы, внешних вызовов нет, цена ошибки —
-- минуты CPU. Дожидаться ручного включения тут значило бы оставить дефект
-- #3469 на месте, просто под другой причиной.
--
-- next_run_at на завтра 06:00 UTC — прогон не выстреливает в момент деплоя
-- (образец: 162_seed_deals_freshness_monitor.sql, 275_landing_stats.sql).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 276/277
-- (таблицы витрины), Handler 'landing_showcase_deals' в product_handlers.py.
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
BEGIN;
-- Конвенция проекта (#2752): блокирующий DDL/DML под lock_timeout.
SET LOCAL lock_timeout = '5s';
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'landing_showcase_deals',
true,
6,
7,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
'{"interval_days": 1, "sample": 200, "limit": 20}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,88 @@
-- 304_asking_to_sold_ratios_region.sql
-- asking_to_sold_ratios: ключ (region_code, rooms_bucket, district) — #3512.
--
-- ПРОБЛЕМА. asking_to_sold_ratios (миграция 080) несёт РОВНО 6 строк (per area-бакет
-- 0..4 + global -1), все с district='' — колонки региона в таблице НЕТ ВООБЩЕ (проверено
-- на проде 13.09.2026: `\d asking_to_sold_ratios` без region_code). Деривация
-- (app/tasks/asking_to_sold_ratio.py) скоупит ОБЕ стороны расчёта на
-- `city ILIKE '%Екатеринбург%'` — ratio физически посчитан только по рынку ЕКБ. Но
-- estimator.py (_get_asking_sold_ratio) читает строку по `rooms_bucket AND district=''`
-- БЕЗ единого понятия региона запроса — тот же коэффициент домножает asking-цену ЛЮБОГО
-- адреса, включая Москву (region_code=77) и Московскую обл. (region_code=50). Рынки
-- разные на порядок: медиана ДКП за 12 мес по region_code — 66: 90 278 ₽/м²,
-- 50: 161 176 ₽/м², 77: 322 976 ₽/м² (замер 13.09.2026) — екатеринбургский sold/asking
-- коэффициент, применённый к московской asking-цене, calibration mismatch, а не оценка.
--
-- ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ (только схема — derivation региона 66 остаётся байт-в-байт
-- прежней, деривация ДРУГИХ регионов теперь считается отдельным SQL-путём в
-- app/tasks/asking_to_sold_ratio.py, сама эта миграция ничего не пересчитывает):
-- 1. ADD COLUMN region_code int NOT NULL DEFAULT 66 — существующие 6 строк ВСЕ
-- екатеринбургские (единственная деривация на момент 080/098/109), DEFAULT
-- backfill'ит их на 66 автоматически при ALTER (Postgres 11+ — DEFAULT на
-- NOT NULL столбце без DEFAULT ранее вычисляется один раз, без rewrite всей
-- таблицы, но здесь таблица микроскопическая — 6 строк, cost неощутим в любом
-- случае).
-- 2. PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district) — строки
-- теперь живут per (регион, area-бакет, district). district остаётся зарезервирован
-- под #647 (гео-районы ВНУТРИ региона), всегда ''.
--
-- ПОЧЕМУ НЕ НОВАЯ ПАРАЛЛЕЛЬНАЯ ТАБЛИЦА (в отличие от 098 tiered-подхода). Там
-- параллельная таблица защищала fetchone()-safety при flag-OFF откате (#928). Здесь
-- отката не предполагается: region_code — обязательный параметр запроса везде, где
-- ratio читается (estimator._get_asking_sold_ratio), и ADD COLUMN NOT NULL DEFAULT 66
-- не меняет СУЩЕСТВУЮЩИЕ 6 строк по значению (только добавляет им явный регион) —
-- старый flag-OFF путь `WHERE rooms_bucket=B AND district=''` для региона 66
-- продолжает возвращать ТУ ЖЕ строку, если добавить `AND region_code=66` (что и
-- делает estimator.py в этом же PR).
--
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS — повторный прогон no-op на колонке.
-- PK-guard (как 298_deal_city_price_bands_region.sql) смотрит ТЕКУЩЕЕ определение PK
-- через pg_get_constraintdef и меняет его ТОЛЬКО если это ещё старый
-- PK(rooms_bucket, district) — повторный прогон видит PK(region_code, rooms_bucket,
-- district) и ничего не трогает. strict/exit-1 на деплое — без SET LOCAL lock_timeout
-- ALTER TABLE мог бы зависнуть под конкурентным чтением; таблица in-memory-масштаба
-- (6 строк), но конвенция проекта (#2752) требует потолок на любой блокирующий DDL.
--
-- ЗАВИСИМОСТИ: 080_asking_to_sold_ratios.sql (таблица, PK(rooms_bucket, district)),
-- 002_core_tables.sql (deals.region_code, listings.region_code — обе уже существуют).
-- Apply after: 303_scrape_schedules_seed_landing_showcase_deals.sql
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE asking_to_sold_ratios
ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66;
COMMENT ON COLUMN asking_to_sold_ratios.region_code IS
'Регион коэффициента (deals.region_code / listings.region_code, #3512). '
'Существующие строки (посчитаны деривацией, скоупленной на ЕКБ, #C2) — 66. '
'Часть составного PK (region_code, rooms_bucket, district).';
-- PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district), идемпотентно:
-- смотрим ТЕКУЩЕЕ определение PK и меняем его только если это ещё старый PK без региона.
DO $$
DECLARE
v_pk_def text;
BEGIN
SELECT pg_get_constraintdef(oid) INTO v_pk_def
FROM pg_constraint
WHERE conrelid = 'asking_to_sold_ratios'::regclass
AND contype = 'p';
IF v_pk_def = 'PRIMARY KEY (rooms_bucket, district)' THEN
ALTER TABLE asking_to_sold_ratios DROP CONSTRAINT asking_to_sold_ratios_pkey;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'asking_to_sold_ratios'::regclass
AND contype = 'p'
) THEN
ALTER TABLE asking_to_sold_ratios
ADD CONSTRAINT asking_to_sold_ratios_pkey
PRIMARY KEY (region_code, rooms_bucket, district);
END IF;
END $$;
COMMIT;

View file

@ -78,7 +78,42 @@ CAVEATS (read these before trusting the numbers)
MAPE и бьёт по классам с редкой застройкой сильнее прочих: часть перекоса
по 4+ комнатам цена такой привязки, а не ошибка модели. Любой замер, где
сделка связывается с КОНКРЕТНЫМ зданием по геометрии (материал стен,
этажность, цена собственного дома), этим скомпрометирован.
этажность, цена собственного дома), этим скомпрометирован. СКЛАДЫВАЕТСЯ
с (e) ниже (неправильное МЕСТО + неправильный СЕГМЕНТ), см. там же.
(e) АНАЛОГИ ПОДБИРАЮТСЯ ПО СИНТЕТИЧЕСКОЙ КОМНАТНОСТИ (#3256). `deals.rooms` —
не комнатность, а бакет площади (границы 30/44/62/85, см. import-rosreestr.sh
и asking_to_sold_ratio.area_bucket): прод-замер 2026-09-11 321 559 из
321 560 сделок удовлетворяют rooms == area_bucket(area_m2), max(rooms) = 4.
Харнес отдаёт это значение в `_fetch_analogs(rooms=deal.rooms)`, который
матчит его с РЕАЛЬНОЙ `listings.rooms`. В прод приходит настоящая комнатность
клиента, т.е. для сделок 85 м² харнес меряет ДРУГОЙ пул аналогов, чем прод:
4-комнатные объявления вместо 3-комнатных. Внутри полосы 85-120 м² медиана
/м² по комнатам 203 692 / 162 896 / 129 735 / 100 000 (2/3/4/5 комнат),
шаг 20-26%. Прод-фикстура 11.09 это подтверждает: медиана аналогов бакета
«4 >=85» = 130 000 /м², т.е. ровно 4-комнатная полоса, хотя 66% вторички
этого метража 3-комнатная. СЛЕДСТВИЕ: бакеты `per_area_bucket` «3 62-85»
и «4 >=85» НЕ ГОДЯТСЯ как цель калибровки `asking_to_sold_ratios` они
меряют смещение чужого пула аналогов, а не промах коэффициента.
ОСТАЛЬНЫЕ БАКЕТЫ НЕ «ЧИСТЫ», а лишь МЕНЬШЕ СМЕЩЕНЫ. Прод-замер 2026-09-12
по ТОМУ ЖЕ пулу, который видит `_fetch_analogs` (is_active, свежесть
LISTINGS_FRESH_DAYS=14, вторичка, регион 66), доля объявлений с
`rooms == area_bucket(area_m2)`:
бакет 0 69.8% (n=2536), 1 63.5% (n=5977), 2 60.1% (n=6176),
бакет 3 54.6% (n=4117), 4 30.9% (n=2088).
Т.е. в бакетах 0-3 модальная комнатность СОВПАДАЕТ с бакетом (подмена
сдвигает пул на соседнюю комнатность у 30-45% лотов, направление в среднем
не одностороннее), а в бакете 4 мода 3 комнаты (54.5% пула), и совпадение
всего 30.9%: там подмена систематически ПЕРЕКЛЮЧАЕТ пул на 4-комнатный.
NB: #3256 снял синтетический ключ со СДЕЛОЧНОЙ стороны (deals в
`_fetch_dkp_corridor`/`_fetch_deals`/`/street-deals`), а этот перекос живёт
на ЛИСТИНГОВОЙ стороне харнеса (`_fetch_analogs(rooms=deal.rooms)`) и
остаётся в силе.
(d) И (e) СКЛАДЫВАЮТСЯ, А НЕ СПОРЯТ: (d) says «сделка привязана к центроиду
улицы, а не к дому» аналоги берутся из неправильного МЕСТА; (e) says
«комнатность аналога синтетическая» из неправильного СЕГМЕНТА. Оба бьют
сильнее всего по крупному метражу (85 м²), поэтому наблюдаемый перекос
«4+ комнаты» это их СУММА, и списывать его целиком на любой один из них
(а тем более на модель) нельзя.
PERFORMANCE
-----------
@ -99,6 +134,10 @@ USAGE
# oblast D: per-city validation (exact deals.city name, not a slug):
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
# #3520 любой регион (choices come from app.services.regions.REGIONS):
# Москва (region_code=77, 212 937 ДКП, 69 138 за последние 12 мес.):
python -m scripts.backtest_estimator --region 77 --since 2024-09-01 --sample 300
"""
from __future__ import annotations
@ -121,6 +160,15 @@ from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.regions import REGIONS, region_for_point
# Регион по умолчанию — Свердловская обл., прежнее (единственное до #3520)
# поведение скрипта. Список допустимых значений — РЕЕСТР REGIONS
# (app/services/regions.py), а не отдельный литерал: регион появляется в
# реестре один раз и автоматически становится доступен и здесь, и в
# msk_raw_import, и в самом эстиматоре.
DEFAULT_BACKTEST_REGION_CODE = 66
def _import_estimator() -> tuple[Any, Any]:
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
@ -1118,6 +1166,7 @@ _SAMPLE_SQL = text(
house_type
FROM deals
WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
@ -1169,6 +1218,7 @@ _SAMPLE_SQL_SCATTERED = text(
house_type
FROM deals
WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
@ -1198,6 +1248,7 @@ _SAMPLE_SQL_SCATTERED_CITY = text(
house_type
FROM deals
WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
@ -1236,10 +1287,9 @@ _CANDIDATES_SQL = text(
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
# _resolve_city_ppm2_band); the default (city=None) path never issues this
# query. region_code defaults to 66 (Свердловская обл.) — this script has no
# region CLI flag yet, so every call is scoped to the oblast, matching every
# existing --city invocation (byte-identical to the pre-298 unscoped lookup,
# which only ever saw region-66 rows since the table was oblast-only before).
# query. region_code (#3520 "любой регион") is threaded from the CLI --region
# flag end-to-end now (default 66, Свердловская обл. — unchanged behaviour for
# every pre-existing invocation, which never passed --region).
_CITY_PPM2_BAND_SQL = text(
"""
SELECT ppm2_min, ppm2_max
@ -1253,10 +1303,13 @@ _CITY_PPM2_BAND_SQL = text(
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
before this change literal identity, not just equal text so the
default CLI invocation (and the frozen EKB regression gate, which never
calls this path at all) see byte-identical SQL.
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object every
time literal identity, not just equal text (asserted by
``test_sample_sql_city_none_returns_original_object``) so callers that
cache/compare the query object see a stable reference. All four SQL
variants (unscoped/scattered × city/no-city) carry a ``region_code``
predicate (#3520 "любой регион") bound from ``_load_sample``'s
``region_code`` param, default 66.
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
scopes the sample to that ``deals.city`` value populated oblast-wide by
@ -1286,6 +1339,7 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
house_type
FROM deals
WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
@ -1313,9 +1367,9 @@ def _resolve_city_ppm2_band(
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
error the globals; read-only best-effort, never raises.
``region_code`` defaults to 66 (Свердловская обл.) this script has no
region CLI flag yet (out of scope, #3051 sub-PR B); every existing
--city caller keeps its byte-identical lookup.
``region_code`` defaults to 66 (Свердловская обл.) for backward-compat
direct callers; ``_load_sample`` (#3520) always passes the actual
``--region`` value through explicitly.
"""
if city is None:
return float(PPM2_MIN), float(PPM2_MAX)
@ -1341,22 +1395,32 @@ def _load_sample(
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
``region_code`` (#3520 "любой регион", default 66 — Свердловская обл.)
scopes the sample to one ``deals.region_code`` value REQUIRED once
``deals`` carries more than one region (212 937 Москва + 113 351
Московская обл. rows alongside 108 623 ЕКБ), otherwise the "unscoped"
query would silently mix regions together. Must be a key of
``app.services.regions.REGIONS`` validated by ``main()``/``_parse_args``
(CLI ``choices``) so this function only ever sees a supported code.
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
value via ``_sample_sql`` and sources the PPM2 sanity band from
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
falls back to the module globals). Default None is byte-identical to the
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
no extra query.
``deal_city_price_bands`` for that (region_code, city) pair
(``_resolve_city_ppm2_band``, falls back to the module globals). Default
None is byte-identical to the pre-oblast-D behaviour: same SQL object,
same PPM2_MIN/PPM2_MAX globals, no extra query.
"""
if city is None:
ppm2_min: float = PPM2_MIN
ppm2_max: float = PPM2_MAX
else:
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city, region_code=region_code)
params: dict[str, Any] = {
"region_code": region_code,
"ppm2_min": ppm2_min,
"ppm2_max": ppm2_max,
"since": since,
@ -1912,8 +1976,21 @@ def _predict_full_spine(
# harness measures the SAME corridor prod actually computes today, not the
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
target_city = m._resolve_target_city(deal.address)
# #3520 "любой регион": mirror estimate_quality's OWN region_code resolution
# (estimator.py ~4868-4870, region_for_point(geo.lat, geo.lon) with a
# DEFAULT_REGION_CODE fallback for points outside every registered bbox) —
# without this, _fetch_dkp_corridor's default (region 66) would scope a
# Москва/Московская обл. deal's corridor query to the WRONG region and
# silently return no rows (or, worse, an unrelated same-named street in 66).
target_region = region_for_point(deal.lat, deal.lon)
target_region_code = target_region.code if target_region else DEFAULT_BACKTEST_REGION_CODE
dkp_raw = m._fetch_dkp_corridor(
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
db,
address=deal.address,
rooms=deal.rooms,
area=deal.area_m2,
city=target_city,
region_code=target_region_code,
)
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
# estimate_quality — no-area / no-address → ([], None) instead of an
@ -2134,6 +2211,7 @@ def run_backtest(
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> dict[str, Any]:
"""Drive the full read-only backtest and return a metrics dict.
@ -2149,12 +2227,29 @@ def run_backtest(
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
to fit on even-id deals and evaluate on the odd-id half for an honest number.
``region_code`` (#3520, default 66) scopes the deal sample to one
``deals.region_code`` see ``_load_sample``.
``city`` (oblast D, default None) scopes the deal sample to one
``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour).
"""
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
deals = _load_sample(
db,
sample=sample,
since=since,
city=city,
scattered=scattered,
seed=seed,
region_code=region_code,
)
logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d)",
len(deals),
since,
city,
region_code,
)
matched_rows: list[tuple[float, float, int]] = []
matched_ids: list[int] = []
@ -2229,6 +2324,7 @@ def run_backtest_full(
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
@ -2254,6 +2350,14 @@ def run_backtest_full(
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
attached to the returned metrics. Default False byte-identical prior output.
``region_code`` (#3520, default 66) scopes the deal sample to one
``deals.region_code`` see ``_load_sample``. Independently of this flag,
``_predict_full_spine`` ALWAYS resolves each deal's OWN region from its
lat/lon via ``regions.region_for_point`` (mirroring prod's
``estimate_quality`` region resolution) before calling
``_fetch_dkp_corridor``, so the ДКП corridor is scoped correctly even if a
sample somehow mixed regions.
``city`` (oblast D, default None) scopes the deal sample to one
``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
@ -2261,9 +2365,21 @@ def run_backtest_full(
``_fetch_dkp_corridor`` (oblast C2 parity fix) see its docstring.
"""
est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
deals = _load_sample(
db,
sample=sample,
since=since,
city=city,
scattered=scattered,
seed=seed,
region_code=region_code,
)
logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d) [full spine]",
len(deals),
since,
city,
region_code,
)
predictions: list[Prediction] = []
@ -2447,6 +2563,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
"прогона подряд отличаются только правкой, а не составом выборки.",
)
p.add_argument(
"--region",
type=int,
default=DEFAULT_BACKTEST_REGION_CODE,
choices=sorted(REGIONS),
help="#3520 'любой регион': deals.region_code to backtest against (default "
f"{DEFAULT_BACKTEST_REGION_CODE} — Свердловская обл., unchanged behaviour). "
"Supported values come from the app.services.regions.REGIONS registry — "
+ ", ".join(f"{code} ({REGIONS[code].name})" for code in sorted(REGIONS))
+ ". An unsupported code is rejected by argparse before any DB call.",
)
p.add_argument(
"--city",
default=None,
@ -2564,9 +2691,10 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit("--calibrate-segments is only supported with --engine full")
logger.info(
"backtest start: engine=%s sample=%d since=%s radius=%dm "
"backtest start: engine=%s region=%d sample=%d since=%s radius=%dm "
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
args.engine,
args.region,
args.sample,
args.since,
args.radius,
@ -2589,6 +2717,7 @@ def main(argv: list[str] | None = None) -> int:
city=args.city,
scattered=(args.spread == "scattered"),
seed=args.seed,
region_code=args.region,
)
else:
metrics = run_backtest(
@ -2601,6 +2730,7 @@ def main(argv: list[str] | None = None) -> int:
city=args.city,
scattered=(args.spread == "scattered"),
seed=args.seed,
region_code=args.region,
)
finally:
db.close()

View file

@ -4,8 +4,10 @@
`--strict-markers` в pyproject.toml не включён, так что незарегистрированный
маркер только предупреждал бы) и сторожит глобальное состояние, которое
переживает отдельный тест: общий rate-limiter POST /estimate (см.
`_reset_estimate_rate_limiter`) и слоты проверки пароля (см.
`_no_leaked_password_verify_slots`).
`_reset_estimate_rate_limiter`), слоты проверки пароля (см.
`_no_leaked_password_verify_slots`) и синглтон Telegram-клиента (см.
`_reset_telegram_shared_client`, #3471 — иначе его rate limiter копит
реальное время между тестами и вешает прогон).
"""
from __future__ import annotations
@ -48,6 +50,35 @@ def _reset_estimate_rate_limiter() -> None:
)
@pytest.fixture(autouse=True)
def _reset_telegram_shared_client():
"""`app.services.tgbot.shared._client` — модульный синглтон `TelegramClient`
(#3471). Его `TelegramGroupRateLimiter` копит РЕАЛЬНЫЕ метки времени
(`time.monotonic()`, ничем не замоканные) по `chat_id` за весь pytest-процесс,
а не по тесту а тестовые настройки `telegram_alerts_chat_id`/
`telegram_support_chat_id` дефолтятся в 0, так что ЛЮБЫЕ тесты, бьющие в
`app.api.v1.support`/`glitchtip` через реальный (не замоканный) shared-клиент,
делят ОДИН и тот же ключ бакета. После N-й (лимит роли, по умолчанию 12)
такой отправки в пределах 60 реальных секунд следующая уходит в настоящий
`asyncio.sleep` до 60с тест не падает, а зависает, и именно так выглядела
смерть CI-джобы на #3494 (обрыв на ~9%, 75с жизни, ни строки об ошибке;
`pytest-timeout` затем добивает зависший тест снаружи).
Фикстура не выключает и не завышает лимит (в проде он ДОЛЖЕН оставаться
ниже площадочного потолка) она просто гарантирует каждому тесту СВЕЖИЙ
клиент (и тем самым свежий, пустой `TelegramGroupRateLimiter`), так же как
`_reset_estimate_rate_limiter` выше делает для `_estimate_limiter`. Сброс
и ДО, и ПОСЛЕ теста тест мог создать клиент через `get_telegram_client()`,
не пройдя явный локальный `_reset_singleton` (см. `test_shared.py`), и не
должен оставить накопленное состояние следующему тесту.
"""
from app.services.tgbot import shared
shared._client = None
yield
shared._client = None
@pytest.fixture(autouse=True)
def _no_leaked_password_verify_slots():
"""Тест не оставляет за собой занятых слотов проверки пароля (#2665, #2714).
@ -150,3 +181,20 @@ def pytest_sessionfinish(session, exitstatus) -> None:
)
if exitstatus == 0:
session.exitstatus = 1
# ── Никакого НАСТОЯЩЕГО сна в тестах ─────────────────────────────────────────
# Фоновый дослальщик алертов GlitchTip (#3471) спит между попытками по
# настоящим часам: три попытки по 30 секунд. `TestClient` из Starlette ждёт
# завершения background-задачи, привязанной к ответу, поэтому один-единственный
# тест на отказ доставки держал весь прогон около минуты, а в CI прогон просто
# умирал по таймауту без единой строки об ошибке.
#
# Ставим паузу в ноль для ВСЕХ тестов: проверять надо, что дослальщик вызван и
# сколько раз, а не то, что интерпретатор умеет спать. Тест, которому нужна
# настоящая пауза, переопределяет значение сам.
@pytest.fixture(autouse=True)
def _no_real_retry_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
from app.tasks import glitchtip_alert_retry
monkeypatch.setattr(glitchtip_alert_retry, "_RETRY_DELAY_S", 0.0)

View file

@ -829,6 +829,46 @@ async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog)
assert dadata._clean_disabled_warned is True
# Вымышленный токен ТОЙ ЖЕ формы, что настоящий DaData API-ключ (40 hex-символов) — НЕ
# реальный секрет. Прод-факт: DaData на 403 кладёт действующий токен в тело ответа
# открытым текстом, и body_preview копирует его в WARNING/ERROR лог как есть (#3471).
FAKE_LEAKED_TOKEN = "deadbeef1234deadbeef1234deadbeef12345678"
CLEAN_TOKEN_LEAK_BODY = {
"timestamp": "2026-09-13T10:00:00.000+00:00",
"status": 403,
"error": "Forbidden",
"message": (
f"Feature 'CLEAN' disabled for token '{FAKE_LEAKED_TOKEN}'. "
"See https://dadata.userecho.com/topics/7784 for help."
),
"path": "/api/v1/clean/address",
}
async def test_clean_address_masks_token_in_feature_disabled_log(caplog) -> None:
"""403 body в реалистичной прод-форме (40-символьный токен) — токен НЕ должен светиться в логе.
Отличие от test_clean_address_logs_feature_disabled_distinctly: тот тест использует
укороченный токен 'xxx' и не проверяет утечку. Здесь токен длины настоящего DaData-ключа,
именно такая форма ушла в docker logs на проде (#3471).
"""
from app.services import dadata
dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle)
transport = _mock_transport_returning(403, CLEAN_TOKEN_LEAK_BODY)
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.WARNING, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
text = caplog.text
assert FAKE_LEAKED_TOKEN not in text, "токен утёк в лог-запись открытым текстом"
# Диагностика (какая услуга выключена) обязана сохраниться несмотря на маскирование.
assert "Стандартизация" in text or "выключена" in text
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
"""401 (или 403 без 'disabled') → сообщение про креды."""
from app.services import dadata

View file

@ -25,6 +25,12 @@ from app.services import location_index as lc
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
_LAT_IN_EKB = 56.838
_LON_IN_EKB = 60.605
_REGION_66 = lc.regions_mod.REGIONS[66]
# A point well inside Moscow's product core (within MKAD) — #3051 region 77.
_LAT_IN_MSK = 55.75
_LON_IN_MSK = 37.62
_REGION_77 = lc.regions_mod.REGIONS[77]
# ── Pure functions ──────────────────────────────────────────────────────────
@ -118,6 +124,7 @@ def test_no_psycopg_v3_colon_colon_cast() -> None:
lc._MEDIAN_PPM2_LOCAL_SQL,
lc._MEDIAN_PPM2_CITYWIDE_SQL,
lc._NEAREST_POI_SQL,
lc._POI_COUNT_IN_REGION_SQL,
):
assert not re.search(r":\w+::", str(sql.text))
@ -142,6 +149,51 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
assert "bbox_west" in sql_text and "bbox_east" in sql_text
# ── #msk-defect-1: медианы обязаны фильтроваться по региону, не только по bbox ──
def test_median_queries_filter_by_region_code() -> None:
"""Прод-замер 13.09.2026: bbox продукт-ядра Москвы геометрически захватывает соседние
region_code=50 (Химки/Реутов/Котельники/Люберцы) без фильтра по региону городская
медиана Москвы считалась с примесью 16 801 подмосковного лота и была занижена на 7.9%
(422 222 вместо 458 208 /м²). У областных строк city IS NULL фильтровать можно
только по region_code, не по имени города."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "region_code = CAST(:region_code AS integer)" in sql_text
def test_median_queries_do_not_admit_null_region_code() -> None:
"""Строгое равенство, БЕЗ `OR region_code IS NULL` — строка без региона не считается
ни за один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля, bbox
ядра 66): 15 858 строк проходят фильтры, 3 из них с NULL region_code; медиана с ними
151 101 /м², без них 151 103 сдвиг 2 рубля на 151 тысяче (0.001%), для 66
статистически ничто. Мягкий `OR NULL` был бы лазейкой того же класса, что и сам
дефект: в `listings` 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox
Москвы (сегодня не проходят по свежести, но могут завтра) `OR NULL` тихо открыл бы
им дорогу обратно в московскую медиану."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "region_code IS NULL" not in sql_text
assert "OR region_code" not in sql_text
def test_local_and_citywide_median_bind_region_code_of_the_given_region() -> None:
"""region_code реально долетает bind-параметром и берётся ИЗ переданного региона, а не
захардкожен иначе фикс работал бы только для 66 и молча ломался на следующем регионе."""
db66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000})])
lc._citywide_median_ppm2(db66, _REGION_66)
assert db66.executed[0][1]["region_code"] == 66
db77 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 458_208.0, "n": 82_719})])
lc._citywide_median_ppm2(db77, _REGION_77)
assert db77.executed[0][1]["region_code"] == 77
local66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25})])
lc._local_median_ppm2(local66, _LAT_IN_EKB, _LON_IN_EKB, 800, _REGION_66)
assert local66.executed[0][1]["region_code"] == 66
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
@ -246,7 +298,9 @@ class _FakeDB:
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
db = _FakeDB([_FakeResult(scalar_value=0)])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
poi, status = lc._fetch_nearby_poi(
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
)
assert poi == []
assert status == "unavailable"
assert len(db.executed) == 1 # only the count probe ran
@ -254,7 +308,9 @@ def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
poi, status = lc._fetch_nearby_poi(
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
)
assert poi == []
assert status == "ok"
@ -267,7 +323,7 @@ def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
]
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
assert status == "ok"
assert len(poi) == 4
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
@ -281,7 +337,7 @@ def test_fetch_nearby_poi_limits_to_top_n() -> None:
for i in range(20)
]
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
assert len(poi) == 7
@ -407,3 +463,64 @@ def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
assert result.poi_status == "unavailable"
assert result.nearby_poi == []
assert result.location_index_pct == 15.0
# ── #msk-defect-1/2: Москва (region 77) — не течёт в EKB, честная POI-деградация ──
def test_covered_region_resolves_moscow_center_to_region_77() -> None:
"""МКАД-центр Москвы должен попадать в продукт-ядро 77, а не в 50 (см. комментарий у
regions._POINT_LOOKUP_ORDER компактный bbox_region проверяется раньше объемлющего)."""
region = lc._covered_region(_LAT_IN_MSK, _LON_IN_MSK)
assert region is not None and region.code == 77
def test_fetch_nearby_poi_gate_uses_regions_own_bbox_not_whole_table() -> None:
"""Дефект (прод 13.09.2026): гейт раньше делал `count(*)` по ВСЕЙ osm_poi_ekb_local
(4 850 екатеринбургских строк, всегда > 0) Москва получала бы poi_status="ok" с
пустым списком. Теперь count скоупится в bbox_product_core региона точки: для 77
внутри его ядра POI нет (0), для 66 те же 4 850 строк, что и раньше."""
db_msk = _FakeDB([_FakeResult(scalar_value=0)])
poi, status = lc._fetch_nearby_poi(
db_msk, _LAT_IN_MSK, _LON_IN_MSK, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_77
)
assert poi == []
assert status == "unavailable" # честная деградация, ТА ЖЕ терминология, что и раньше
# bbox в запросе — Москвы, не Екатеринбурга (это и есть суть фикса).
poi_count_params = db_msk.executed[0][1]
lat_min, lat_max, lon_min, lon_max = _REGION_77.bbox_product_core
assert poi_count_params["bbox_south"] == lat_min
assert poi_count_params["bbox_north"] == lat_max
assert poi_count_params["bbox_west"] == lon_min
assert poi_count_params["bbox_east"] == lon_max
def test_fetch_nearby_poi_gate_still_scopes_to_ekb_bbox_for_region_66() -> None:
"""Regression: для 66 гейт обязан продолжать спрашивать про bbox ЕКБ — byte-for-byte,
просто теперь явно скоупенный, а не «случайно совпадающий» с count(*) по всей таблице."""
db = _FakeDB([_FakeResult(scalar_value=4850), _FakeResult(mapping_rows=[])])
lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66)
poi_count_params = db.executed[0][1]
lat_min, lat_max, lon_min, lon_max = _REGION_66.bbox_product_core
assert poi_count_params["bbox_south"] == lat_min
assert poi_count_params["bbox_north"] == lat_max
assert poi_count_params["bbox_west"] == lon_min
assert poi_count_params["bbox_east"] == lon_max
def test_compute_location_index_moscow_point_isolates_region_and_degrades_honestly() -> None:
"""End-to-end #3051 Москва: citywide/local запросы обязаны нести region_code=77 (не 66,
не отсутствие фильтра), а poi_status обязан быть честным "unavailable" НЕ "ok" с
пустым списком, как было до фикса дефекта 2."""
db = _FakeDB(
[
_FakeResult(scalar_value=0), # poi-count in Moscow core: 0
_FakeResult(mapping_one={"median_ppm2": None, "n": 0}), # citywide: no MSK data yet
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_MSK, lon=_LON_IN_MSK)
assert result.status == "insufficient_data" # честно: листингов Москвы пока нет
assert result.poi_status == "unavailable"
assert result.nearby_poi == []
citywide_params = db.executed[1][1]
assert citywide_params["region_code"] == 77

View file

@ -420,8 +420,10 @@ def test_all_candidates_banned_raises_pool_exhausted_not_env_fallback(
assert exc_info.value.pool_total == 2
assert exc_info.value.banned_for_source == 2
assert exc_info.value.unhealthy_or_disabled == 0
errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
assert any("fail-closed" in rec.message.lower() for rec in errors)
# fail-closed без здорового узла — штатный исход скрапинга, а не инцидент;
# понижено до warning, чтобы не шуметь в GlitchTip (было logger.error).
warnings = [rec for rec in caplog.records if rec.levelname == "WARNING"]
assert any("fail-closed" in rec.message.lower() for rec in warnings)
# НЕ должно быть "обход пула" / "static-fallback" в логах — env не тронут.
assert not any("static-fallback" in rec.message for rec in caplog.records)
@ -429,8 +431,10 @@ def test_all_candidates_banned_raises_pool_exhausted_not_env_fallback(
def test_exhausted_and_empty_pool_log_texts_are_distinct(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Регрессия на замечание ревью: "пуст" и "все отсеяны" — РАЗНЫЕ формулировки И
разные уровни (WARNING vs ERROR), иначе их нельзя различить в логах/алертах."""
"""Регрессия на замечание ревью: "пуст" и "все отсеяны" — РАЗНЫЕ формулировки,
иначе их нельзя различить в логах/алертах. Оба сценария штатный исход
скрапинга, поэтому оба теперь warning (было WARNING vs ERROR), различимость
держится на тексте, не на уровне."""
now = datetime.now(UTC)
with caplog.at_level("WARNING"):
@ -452,8 +456,8 @@ def test_exhausted_and_empty_pool_log_texts_are_distinct(
exhausted_messages = {rec.levelname: rec.message for rec in caplog.records}
assert "ERROR" not in empty_pool_messages
assert "ERROR" in exhausted_messages
assert empty_pool_messages.get("WARNING") != exhausted_messages.get("ERROR")
assert "ERROR" not in exhausted_messages
assert empty_pool_messages.get("WARNING") != exhausted_messages.get("WARNING")
def test_disabled_proxy_raises_pool_exhausted() -> None:

View file

@ -50,6 +50,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
import pytest
from app.services import proxy_pool
@ -1527,3 +1528,74 @@ def test_clear_source_bans_resets_escalation() -> None:
assert ban["ban_count"] == 1
expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS)
assert abs((ban["banned_until"] - expected).total_seconds()) < 60
# ── health-probe failure logging (#3471 — GlitchTip/log noise) ──────────────
# httpx.ProxyError (типично 407 от провайдера) раньше падал в generic
# `except Exception: ... exc_info=True` внутри `_probe_proxy` — полный traceback
# на КАЖДЫЙ провал, хотя это штатное состояние пула (184 строки/сутки на
# проде), а не инцидент приложения. Тесты ниже бьют по РЕАЛЬНОМУ `_probe_proxy`
# (не монки-заглушке, как в тестах `run_proxy_healthcheck` выше) — только так
# видно, что осталось от логирования при живом httpx-исключении.
async def test_probe_proxy_proxy_error_logs_single_line_without_traceback(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
async def _broken_get(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response:
raise httpx.ProxyError("407 Proxy Authentication Required")
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
ok, exit_ip, latency_ms, fail_kind = await proxy_pool._probe_proxy(
"http://u:p@h1:8080"
)
assert ok is False
assert exit_ip is None
assert latency_ms is None
assert fail_kind == "proxy_error"
records = [r for r in caplog.records if r.name == "app.services.proxy_pool"]
assert len(records) == 1, "провал ipify-пробы обязан лечь ОДНОЙ строкой, не пачкой"
record = records[0]
assert record.exc_info is None, "проба — штатная операция, полный traceback не нужен"
assert "proxy_error" in record.message
assert "407" in record.message # причина (текст исключения) видна без трейса
async def test_healthcheck_counts_proxy_error_as_failed_without_traceback(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Итоговая строка `checked=.../ok=.../failed=...` не ломается провалом
вида ProxyError, а сам провал не тащит traceback в лог прогона."""
db = FakeSession([_proxy(1, fails=0)])
async def _broken_get(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response:
raise httpx.ProxyError("407 Proxy Authentication Required")
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
# INFO (не WARNING): итоговая сводка `healthcheck done` логируется на INFO —
# порог ниже WARNING нужен, чтобы её тоже поймать в этом же прогоне.
with caplog.at_level("INFO", logger="app.services.proxy_pool"):
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["checked"] == 1
assert counters["ok"] == 0
assert counters["failed"] == 1
assert db._by_id(1)["consecutive_fails"] == 1
summary = [
r
for r in caplog.records
if r.name == "app.services.proxy_pool" and "healthcheck done" in r.message
]
assert len(summary) == 1
assert "checked=1" in summary[0].message
assert "ok=0" in summary[0].message
assert "failed=1" in summary[0].message
assert not any(r.exc_info for r in caplog.records if r.name == "app.services.proxy_pool")

View file

@ -22,6 +22,12 @@ Coverage (per task spec + review follow-up):
- сбой БД (SQLAlchemyError) во время обработки rollback() ПЕРЕД save_offset,
offset всё равно сдвигается без этого следующий поллинг переиграл бы тот
же апдейт и задублировал зеркало клиента в топике (#3 review)
- ТРАНЗИЕНТНЫЙ сетевой отказ (TelegramNetworkError) на доставке ответа оператора
offset НЕ сдвигается, апдейт переигрывается и доходит до клиента; на потолке
`_MAX_NETWORK_REPLAYS` offset всё-таки сдвигается (поток не заклинен); poll loop
прерывает разбор пачки на неподтверждённом апдейте (#tg-connection-resilience)
- провал ВТОРИЧНОГО уведомления оператору в топик не отменяет основную ветку
(is_blocked остаётся, offset сдвигается) и логируется отдельной строкой
- /start приветствие без зеркалирования
- Telegram 403 на доставку оператору is_blocked + уведомление в топике
- TELEGRAM_SUPPORT_CHAT_ID не задан клиенту уходит "сервис недоступен"
@ -45,7 +51,7 @@ from sqlalchemy.exc import SQLAlchemyError
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.tgbot import bridge
from app.services.tgbot.client import TelegramClient
from app.services.tgbot.client import TelegramClient, TelegramNetworkError
SUPPORT_CHAT_ID = -100123456789
SUPPORT_TOPIC_ID = 42
@ -74,6 +80,10 @@ class FakeBridgeStorage:
self._next_id = 1
self.clock_s: float = 0.0
self.fail_next_record_message = False
# #3471 P0: следующий вызов record_web_out_message кидает SQLAlchemyError
# (симулирует обрыв коннекта к БД на веб-ветке — для веб-треда эта запись
# И ЕСТЬ доставка клиенту) и сбрасывается в False.
self.fail_next_record_web_out_message = False
# #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
# (thread_id, support_chat_id) — второй элемент моделирует колонку
# web_support_messages.support_chat_id (review M1); None = легаси wildcard.
@ -152,9 +162,11 @@ class FakeBridgeStorage:
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
"""support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
support_chat_id не матчится None (легаси/дефолт) матчится всегда."""
support_chat_id не матчится None (легаси/дефолт) матчится всегда.
БЕЗ фильтра по direction (#3471 P0) — реплай на СВОЙ предыдущий ответ
(direction='out') резолвится так же, как реплай на зеркало клиента."""
for m in reversed(self.messages):
if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
if m["topic_message_id"] != topic_message_id:
continue
entry_chat_id = m.get("support_chat_id")
if entry_chat_id is not None and entry_chat_id != support_chat_id:
@ -179,15 +191,32 @@ class FakeBridgeStorage:
return thread_id
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
self,
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
) -> None:
if self.fail_next_record_web_out_message:
self.fail_next_record_web_out_message = False
raise SQLAlchemyError("simulated DB failure (deploy connection reset)")
self.web_out_messages.append(
{
"thread_id": thread_id,
"text_body": text_body,
"operator_tg_id": operator_tg_id,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
}
)
# Зеркалим в `web_topic_to_thread` (deep review PR #3479) — реальный
# `record_outbound` пишет ту же строку в web_support_messages, которую
# потом читает `find_thread_by_topic_message`; без этого фейк не мог бы
# поймать баг "out-строка с support_chat_id=NULL — вечный wildcard".
if topic_message_id is not None:
self.web_topic_to_thread[topic_message_id] = (thread_id, support_chat_id)
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
@ -849,6 +878,146 @@ async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
assert storage.get_offset() == 62
async def test_group_reply_to_web_mirror_db_failure_notifies_operator_and_advances_offset() -> None:
"""#3471 P0: сбой БД на `record_web_out_message` — для веб-треда эта запись И
ЕСТЬ доставка клиенту (веб-фронт читает её polling'ом), поэтому тихий откат
означал бы навсегда потерянный ответ оператора (воспроизведено на проде
31.08.2026 клиент kopylov). Теперь: rollback уведомление оператору
реплаем в топик, что ответ НЕ доставлен offset всё равно сдвигается (та же
политика, что у любого другого `SQLAlchemyError` в `process_update` сбой
БД не переигрывается, human-in-the-loop retry заменяет технический)
исключение наружу НЕ улетает."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[310] = (50, SUPPORT_CHAT_ID)
storage.fail_next_record_web_out_message = True
update = {
"update_id": 70,
"message": _group_reply_message(reply_to_message_id=310, message_id=210),
}
await bridge.process_update(update, client, storage)
# Ответ НЕ попал в web_out_messages (запись упала), но и не потерян молча.
assert storage.web_out_messages == []
assert storage.rollbacks == 1
methods = [m for m, _ in calls]
assert methods == ["sendMessage"]
notice = calls[0][1]
assert notice["chat_id"] == SUPPORT_CHAT_ID
assert notice["reply_to_message_id"] == 210
assert "НЕ доставлен" in notice["text"]
# Сбой БД не переигрывается (общая политика SQLAlchemyError) — offset сдвинут и закоммичен.
assert storage.get_offset() == 70
assert storage.commits == 1
async def test_group_reply_to_web_mirror_db_failure_and_notify_failure_logs_error(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#3471 P0: сбой БД НА веб-ответе, а следом ещё и уведомление оператору не
ушло (Telegram недоступен) полная тишина по обоим каналам. `process_update`
всё равно не падает и offset сдвигает, но остаётся `logger.error` с
идентификаторами (thread_id/message_id), НЕ текстом по нему человек найдёт
ответ оператора в топике вручную."""
calls: list[tuple[str, dict[str, Any]]] = []
# 403 (не 5xx) — не ретраится клиентом, `_notify_topic` падает быстро и
# детерминированно (без реальных retry-пауз).
client = _make_client({"sendMessage": 403}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[311] = (51, SUPPORT_CHAT_ID)
storage.fail_next_record_web_out_message = True
update = {
"update_id": 71,
"message": _group_reply_message(reply_to_message_id=311, message_id=211),
}
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
await bridge.process_update(update, client, storage)
assert storage.web_out_messages == []
assert "потерян молча" in caplog.text
assert "51" in caplog.text # thread_id узнаваем в логе
assert storage.get_offset() == 71 # сбой БД не переигрывается — offset сдвинут
assert storage.commits == 1
async def test_group_reply_to_own_previous_web_reply_resolves_thread() -> None:
"""#3471 P0 (пункт 3): реплай оператора на СВОЙ предыдущий веб-ответ (не на
исходное зеркало клиента) теперь тоже резолвится `record_outbound`
сохраняет topic_message_id исходящей записи, `find_thread_by_topic_message`
больше не фильтрует по direction.
Deep review PR #3479: новая out-строка ОБЯЗАНА писаться с ТЕКУЩИМ
`support_chat_id`, а не NULL NULL матчится `find_thread_by_topic_message`
как лениентный wildcard "любой чат" (легаси до 187/188), т.е. NULL сделал бы
КАЖДУЮ out-строку вечным wildcard-совпадением при ротации support-группы.
Эта проверка падает на дефектной реализации (support_chat_id не передавался
в `record_web_out_message`), даже когда resolve выше внешне "работает"."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
# Симулируем уже сохранённый предыдущий ответ оператора (topic_message_id=320)
# так, как это сделал бы реальный record_outbound после фикса.
storage.web_topic_to_thread[320] = (52, SUPPORT_CHAT_ID)
update = {
"update_id": 72,
"message": _group_reply_message(
reply_to_message_id=320, message_id=220, text="Продолжение"
),
}
await bridge.process_update(update, client, storage)
assert len(storage.web_out_messages) == 1
assert storage.web_out_messages[0]["thread_id"] == 52
assert storage.web_out_messages[0]["topic_message_id"] == 220
# Deep review PR #3479: НЕ NULL — иначе эта строка стала бы вечным wildcard.
assert storage.web_out_messages[0]["support_chat_id"] == SUPPORT_CHAT_ID
async def test_group_reply_to_own_previous_tg_reply_resolves_target_chat() -> None:
"""#3471 P0 (пункт 3): та же история для Telegram-пути — реплай оператора на
СВОЙ предыдущий ответ клиенту (direction='out', topic_message_id теперь
заполнен) резолвится в chat_id, а не проваливается в orphan-check.
Deep review PR #3479: новая out-строка ОБЯЗАНА писаться с ТЕКУЩИМ
`support_chat_id` (симметрично in-ветке) иначе она стала бы вечным
wildcard в `find_chat_by_topic_message` при ротации support-группы, и
reply мог бы увести ответ ЧУЖОМУ клиенту."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 601}}, calls)
storage = FakeBridgeStorage()
# Предыдущий ответ оператора клиенту, зафиксированный с topic_message_id
# (id этого ответа В ТОПИКЕ) — то, что теперь пишет TG-путь `_handle_group_reply`.
storage.record_message(
chat_id=555,
direction="out",
tg_message_id=500,
topic_message_id=330,
kind="text",
text_body="Первый ответ оператора",
operator_tg_id=777,
support_chat_id=SUPPORT_CHAT_ID,
)
update = {
"update_id": 73,
"message": _group_reply_message(reply_to_message_id=330, message_id=230, text="Уточнение"),
}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
assert methods == ["copyMessage"]
assert calls[0][1]["chat_id"] == 555
assert len(storage.messages) == 2 # исходный 'out' + новый 'out'
assert storage.messages[-1]["topic_message_id"] == 230
# Deep review PR #3479: НЕ NULL — иначе эта строка стала бы вечным wildcard.
assert storage.messages[-1]["support_chat_id"] == SUPPORT_CHAT_ID
# ── C) дедуп ──────────────────────────────────────────────────────────────────
@ -973,3 +1142,219 @@ async def test_update_from_unrelated_chat_is_ignored_but_offset_advances() -> No
assert calls == []
assert storage.messages == []
assert storage.get_offset() == 40
# ── сетевая устойчивость (#tg-connection-resilience) ─────────────────────────
@pytest.fixture(autouse=True)
def _reset_network_replay_attempts() -> None:
"""`bridge._network_replay_attempts` — module-level словарь, его состояние
иначе протекало бы между тестами (потолок переигрываний виден глобально)."""
bridge._network_replay_attempts.clear()
def _network_boom(method: str = "copyMessage"):
"""Асинхронная заглушка метода клиента, изображающая исчерпанный бюджет ретраев."""
async def _raise(*_args: object, **_kwargs: object) -> dict[str, Any]:
raise TelegramNetworkError(method, "ConnectTimeout", 4)
return _raise
async def test_network_failure_on_operator_reply_keeps_offset_for_replay(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Главный сценарий потери: оператор ответил, Telegram в этот момент недоступен.
Раньше `finally: save_offset` подтверждал апдейт ответ не доходил до клиента
НИКОГДА (Telegram апдейт больше не отдаёт, записи нет, оператор уверен, что
ответил). Теперь offset остаётся прежним, апдейт переигрывается и доходит.
"""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 42}}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=1,
topic_message_id=100,
kind="text",
text_body="вопрос клиента",
operator_tg_id=None,
)
update = {"update_id": 40, "message": _group_reply_message(reply_to_message_id=100)}
# Падает РОВНО первый copyMessage (monkeypatch.undo() тут не годится: он снял бы
# и autouse-патчи настроек support-группы, и ветка просто перестала бы работать).
real_copy = client.copy_message
failures = {"left": 1}
async def flaky_copy(**kwargs: Any) -> dict[str, Any]:
if failures["left"] > 0:
failures["left"] -= 1
raise TelegramNetworkError("copyMessage", "ConnectTimeout", 4)
return await real_copy(**kwargs)
monkeypatch.setattr(client, "copy_message", flaky_copy)
advanced = await bridge.process_update(update, client, storage)
assert advanced is False
assert storage.get_offset() == 0 # апдейт НЕ подтверждён — Telegram отдаст его снова
assert storage.commits == 0
assert storage.rollbacks == 1 # частичные записи не уедут чужим commit'ом
assert len(storage.messages) == 1 # фейковой записи 'out' не появилось
# Переигрывание: сеть починилась — тот же апдейт доставляется и подтверждается.
advanced = await bridge.process_update(update, client, storage)
assert advanced is True
assert storage.get_offset() == 40
assert [m for m, _ in calls] == ["copyMessage"]
out_rec = storage.messages[-1]
assert out_rec["direction"] == "out"
assert out_rec["chat_id"] == 555
async def test_network_failure_stops_advancing_only_until_replay_cap(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Потолок переигрываний: «вечно недоставляемый» апдейт не должен заклинить
поток навсегда (ровно то, от чего защищал прежний безусловный `finally`)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=1,
topic_message_id=100,
kind="text",
text_body="вопрос клиента",
operator_tg_id=None,
)
monkeypatch.setattr(client, "copy_message", _network_boom())
update = {"update_id": 41, "message": _group_reply_message(reply_to_message_id=100)}
for _ in range(bridge._MAX_NETWORK_REPLAYS - 1):
assert await bridge.process_update(update, client, storage) is False
assert storage.get_offset() == 0
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
advanced = await bridge.process_update(update, client, storage)
assert advanced is True
assert storage.get_offset() == 41 # поток разблокирован
assert "потолок переигрываний" in caplog.text
# Счётчик снят — словарь не растёт от апдейта к апдейту.
assert bridge._network_replay_attempts == {}
async def test_poll_loop_stops_batch_on_unconfirmed_update(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Offset у Telegram — единая «высшая отметка»: подтвердив СЛЕДУЮЩИЙ апдейт
пачки, мы неявно подтвердили бы неудавшийся, и переигрывания не случилось бы.
Поэтому разбор пачки обрывается на первом неподтверждённом апдейте."""
processed: list[int] = []
async def fake_process(update: dict[str, Any], client: object, storage: object) -> bool:
processed.append(update["update_id"])
return update["update_id"] != 51 # 51 — сетевой отказ
monkeypatch.setattr(bridge, "process_update", fake_process)
iteration = {"n": 0}
def fake_shutdown() -> bool:
iteration["n"] += 1
return iteration["n"] > 1 # ровно одна итерация poll loop
monkeypatch.setattr(bridge, "shutdown_requested", fake_shutdown)
monkeypatch.setattr(bridge, "SqlBridgeStorage", lambda _db: FakeBridgeStorage())
class _FakeSession:
def __enter__(self) -> _FakeSession:
return self
def __exit__(self, *_exc: object) -> bool:
return False
class _FakeClient:
async def get_updates(self, **_kwargs: object) -> list[dict[str, Any]]:
return [{"update_id": 51}, {"update_id": 52}, {"update_id": 53}]
await bridge.run_poll_loop(_FakeClient(), _FakeSession, poll_timeout_s=1)
assert processed == [51] # 52/53 придут заново следующим getUpdates
async def test_topic_notification_failure_does_not_cancel_main_branch(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Уведомление в топик — вторичное действие: его сетевой отказ не отменяет
пометку is_blocked и не превращает апдейт в переигрываемый."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": 403}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=1,
topic_message_id=100,
kind="text",
text_body="вопрос клиента",
operator_tg_id=None,
)
monkeypatch.setattr(client, "send_message", _network_boom("sendMessage"))
update = {"update_id": 42, "message": _group_reply_message(reply_to_message_id=100)}
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
advanced = await bridge.process_update(update, client, storage)
assert advanced is True
assert 555 in storage.blocked # основная ветка отработала
assert storage.get_offset() == 42
assert storage.commits == 1
assert len(storage.messages) == 1
assert "уведомление оператору в топик" in caplog.text
assert "chat_id=555" in caplog.text # только идентификатор, без текста переписки
async def test_topic_notification_uses_interactive_send_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Уведомление шлётся с УЗКИМ бюджетом: воркерный дефолт (5 ретраев, backoff
до 30с, полный retry_after на 429) застопорил бы весь poll loop на минуты."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": 403}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=1,
topic_message_id=100,
kind="text",
text_body="вопрос клиента",
operator_tg_id=None,
)
sent: list[dict[str, Any]] = []
async def recording_send(**kwargs: Any) -> dict[str, Any]:
sent.append(kwargs)
return {"message_id": 1}
monkeypatch.setattr(client, "send_message", recording_send)
update = {"update_id": 43, "message": _group_reply_message(reply_to_message_id=100)}
await bridge.process_update(update, client, storage)
assert len(sent) == 1
assert sent[0]["timeout"] == bridge._NOTIFY_SEND_TIMEOUT_S
assert sent[0]["max_retries"] == bridge._NOTIFY_SEND_MAX_RETRIES
assert sent[0]["max_backoff"] == bridge._NOTIFY_SEND_MAX_BACKOFF_S
assert sent[0]["chat_id"] == SUPPORT_CHAT_ID

View file

@ -16,18 +16,31 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.tgbot.client import TelegramApiError, TelegramClient
from app.services.tgbot.client import (
_CONNECT_TIMEOUT_S,
_POOL_TIMEOUT_S,
_WRITE_TIMEOUT_S,
TelegramApiError,
TelegramClient,
TelegramNetworkError,
)
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _install_transport(handler) -> None:
def _install_transport(handler) -> list[httpx.AsyncClient]:
"""Подменяет транспорт. Возвращает список СОЗДАННЫХ AsyncClient — по нему
видно, переиспользуется ли один клиент или он плодится на каждый запрос."""
transport = httpx.MockTransport(handler)
created: list[httpx.AsyncClient] = []
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
client = _REAL_ASYNC_CLIENT(transport=transport)
created.append(client)
return client
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
return created
@pytest.fixture(autouse=True)
@ -216,3 +229,258 @@ async def test_network_error_log_keeps_text_when_exception_has_one(caplog) -> No
assert warnings, "не было предупреждения о сетевом сбое"
assert "ConnectTimeout" in warnings[0], f"нет типа: {warnings[0]!r}"
assert "таймаут соединения" in warnings[0], f"текст исключения потерян: {warnings[0]!r}"
@pytest.mark.parametrize(
("exc_type", "expected_attempts"),
[
(httpx.ConnectTimeout, 2),
(httpx.RemoteProtocolError, 2),
(httpx.ProxyError, 2),
(httpx.DecodingError, 1),
],
)
async def test_request_failure_raises_own_type_not_raw_httpx(
exc_type: type[Exception], expected_attempts: int
) -> None:
"""Любой отказ запроса — наружу свой тип, а не сырой httpx.
Сырой httpx пролетал мимо `except TelegramApiError` во всех трёх HTTP-ручках
и превращался в 500 вместо задуманного 502 (#3456). Первый заход закрыл
только `(TimeoutException, NetworkError)`, а `RemoteProtocolError` («Server
disconnected without sending a response» бытовой ответ api.telegram.org из
РФ), `ProxyError` и `DecodingError` сёстры по `TransportError`/
`RequestError`, не наследники `NetworkError`, и дыра оставалась открытой.
Тип отказа при этом терять нельзя он остаётся в `__cause__`, иначе в
GlitchTip не отличить таймаут соединения от сброса TLS.
"""
def handler(request: httpx.Request) -> httpx.Response:
raise exc_type("сбой транспорта")
_install_transport(handler)
with pytest.raises(TelegramNetworkError) as caught:
await TelegramClient(token="t").send_message(chat_id=-1, text="x", max_retries=1)
assert caught.value.method == "sendMessage"
assert caught.value.attempts == expected_attempts, "число попыток должно попасть в исключение"
assert exc_type.__name__ in caught.value.reason
assert isinstance(caught.value.__cause__, exc_type), "причина потеряна"
async def test_remote_protocol_error_is_retried_but_decoding_error_is_not() -> None:
"""Разница бюджета между двумя `except`: что чинится повтором, а что нет.
`RemoteProtocolError` «площадка не ответила», ровно как таймаут: повтор
осмыслен, и он наследует уже принятый здесь риск at-least-once (запрос мог
дойти до Telegram, потерялся ответ) тот же, что у `ReadTimeout`.
`DecodingError` испорченный ответ / кривая конфигурация: пять попыток с
backoff подвесили бы интерактивную ручку почти на минуту без единого шанса
на успех.
"""
counts: dict[str, int] = {}
async def _attempts_for(exc_type: type[Exception]) -> int:
counts[exc_type.__name__] = 0
def handler(request: httpx.Request) -> httpx.Response:
counts[exc_type.__name__] += 1
raise exc_type("сбой транспорта")
_install_transport(handler)
with pytest.raises(TelegramNetworkError):
await TelegramClient(token="t").send_message(chat_id=-1, text="x", max_retries=2)
return counts[exc_type.__name__]
assert await _attempts_for(httpx.RemoteProtocolError) == 3, (
"обрыв протокола обязан ретраиться наравне с таймаутом"
)
assert await _attempts_for(httpx.DecodingError) == 1, (
"битый ответ ретраить нельзя — повтор не лечит, а бюджет ручки съедает"
)
async def test_network_error_is_not_api_error() -> None:
"""`bridge` разбирает `error_code` (403 «бот заблокирован») — недоступность
площадки в этот разбор попадать не должна, у неё кода ответа нет вовсе."""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectTimeout("таймаут соединения")
_install_transport(handler)
with pytest.raises(TelegramNetworkError) as caught:
await TelegramClient(token="t").send_message(chat_id=-1, text="x", max_retries=0)
assert not isinstance(caught.value, TelegramApiError)
# --- раздельные таймауты (#tg-connection-resilience) --------------------------
async def _capture_request_timeout(call) -> dict[str, float]:
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["timeout"] = request.extensions.get("timeout")
return httpx.Response(200, json={"ok": True, "result": []})
_install_transport(handler)
await call(TelegramClient(token="t"))
assert captured["timeout"] is not None, "таймаут не доехал до запроса"
return captured["timeout"]
async def test_long_poll_timeout_applies_to_read_only_not_to_connect() -> None:
"""40 секунд запаса long-poll'а — это бюджет ОТВЕТА, а не установки соединения.
Скаляр в httpx разворачивается в connect=read=write=pool, поэтому
`getUpdates` ждал 40с и коннекта тоже. Живой connect до api.telegram.org из
прод-контейнера 0.036с; худший цикл из-за этого растягивался на ~174с
(4 попытки × 40с + backoff), и всё это время бот не видел ответов оператора.
"""
timeout = await _capture_request_timeout(lambda tg: tg.get_updates(offset=0, timeout=30))
assert timeout["read"] == 40.0, "long-poll обязан сохранить свои 30+10с на ответ"
assert timeout["connect"] == _CONNECT_TIMEOUT_S, "connect не должен наследовать long-poll"
assert timeout["write"] == _WRITE_TIMEOUT_S
assert timeout["pool"] == _POOL_TIMEOUT_S
async def test_interactive_call_keeps_its_own_narrow_read_budget() -> None:
"""Узкий интерактивный бюджет ручки — тоже read, и он не подменяется дефолтом."""
timeout = await _capture_request_timeout(
lambda tg: tg.send_message(chat_id=1, text="x", timeout=6.0)
)
assert timeout["read"] == 6.0
assert timeout["connect"] == _CONNECT_TIMEOUT_S
# --- переиспользование соединения --------------------------------------------
async def test_two_calls_share_one_httpx_client_and_aclose_closes_it() -> None:
"""Один `httpx.AsyncClient` на жизнь `TelegramClient`, а не на запрос.
Раньше клиент создавался ВНУТРИ цикла ретраев: нулевой keep-alive, полный
TCP+TLS-хендшейк на каждый запрос и на каждую попытку.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
created = _install_transport(handler)
tg = TelegramClient(token="t")
await tg.send_message(chat_id=1, text="раз")
await tg.send_message(chat_id=1, text="два")
assert len(created) == 1, f"клиент пересоздаётся на запрос: {len(created)} штук"
assert not created[0].is_closed
await tg.aclose()
assert created[0].is_closed, "aclose() обязан закрыть пул соединений"
async def test_retries_reuse_the_same_http_client() -> None:
"""Ретраи не пересоздают клиент — иначе повтор платит за хендшейк заново."""
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] < 3:
return httpx.Response(502, json={"ok": False, "error_code": 502, "description": "gw"})
return httpx.Response(200, json={"ok": True, "result": {"message_id": 7}})
created = _install_transport(handler)
await TelegramClient(token="t").send_message(chat_id=1, text="x")
assert calls["n"] == 3, "бюджет ретраев изменился незаметно"
assert len(created) == 1, "на каждую попытку создаётся новый клиент"
async def test_async_context_manager_closes_client_on_exit() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
created = _install_transport(handler)
async with TelegramClient(token="t") as tg:
await tg.send_message(chat_id=1, text="x")
assert len(created) == 1
assert created[0].is_closed
# ── Ретранслятор через Beget: фолбэк на прямой путь (#3471) ────────────────
#
# Единственный `httpx.AsyncClient` (общий для обоих адресов через тот же
# MockTransport) позволяет различать «запрос на ретранслятор» от «запрос
# напрямую» по хосту в `request.url.host` — так проверяется, что при отказе
# ретранслятора клиент реально уходит на api.telegram.org, а не молчит.
async def test_relay_transport_failure_falls_back_to_direct_once() -> None:
"""Отказ ретранслятора не должен ронять бота: одна попытка напрямую."""
seen_hosts: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_hosts.append(request.url.host)
if request.url.host == "relay.example.com":
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(200, json={"ok": True, "result": {}})
_install_transport(handler)
client = TelegramClient(
token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh"
)
result = await client.send_message(chat_id=1, text="hi")
assert result == {}
assert seen_hosts == ["relay.example.com", "api.telegram.org"]
async def test_relay_secret_header_sent_only_to_relay_not_to_direct_fallback() -> None:
captured: list[str | None] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request.headers.get("X-Relay-Secret"))
if request.url.host == "relay.example.com":
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(200, json={"ok": True, "result": {}})
_install_transport(handler)
client = TelegramClient(
token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh"
)
await client.send_message(chat_id=1, text="hi")
assert captured == ["shh", None], "секрет ретранслятора не должен уходить напрямую в Telegram"
async def test_no_relay_configured_behaves_exactly_as_direct_path_before() -> None:
"""Пустая настройка — это откат: поведение НЕ должно отличаться от прежнего."""
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.host == "api.telegram.org"
assert "X-Relay-Secret" not in request.headers
return httpx.Response(200, json={"ok": True, "result": {}})
_install_transport(handler)
result = await TelegramClient(token="fake-token").send_message(chat_id=1, text="hi")
assert result == {}
async def test_relay_success_never_touches_direct_host() -> None:
seen_hosts: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_hosts.append(request.url.host)
return httpx.Response(200, json={"ok": True, "result": {}})
_install_transport(handler)
client = TelegramClient(
token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh"
)
await client.send_message(chat_id=1, text="hi")
assert seen_hosts == ["relay.example.com"]

View file

@ -0,0 +1,80 @@
"""Жизненный цикл общего на приложение `TelegramClient` (`app.services.tgbot.shared`).
Смысл модуля ровно в том, что экземпляр ОДИН: до правки три HTTP-ручки создавали
клиент на каждый входящий запрос, и keep-alive не было ни на каком уровне. Если
синглтон однажды перестанет быть синглтоном, тесты ручек этого не заметят (они
подменяют аксессор целиком), а на проде вернутся TLS-хендшейки на каждое сообщение.
Поэтому проверяем идентичность и закрытие здесь, отдельно.
Реальных сетевых вызовов нет: `TelegramClient` создаёт `httpx.AsyncClient` лениво,
при первом запросе, а мы до запросов не доходим.
"""
from __future__ import annotations
import os
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.tgbot import shared
from app.services.tgbot.client import TelegramClient
@pytest.fixture(autouse=True)
def _reset_singleton():
"""Глобал модуля не должен течь между тестами — иначе порядок решает исход."""
shared._client = None
yield
shared._client = None
def test_get_returns_same_instance() -> None:
"""Два обращения — один объект. Это и есть весь смысл модуля."""
first = shared.get_telegram_client()
second = shared.get_telegram_client()
assert isinstance(first, TelegramClient)
assert first is second, "аксессор создал второй клиент — пул перестал переиспользоваться"
def test_init_returns_the_shared_instance() -> None:
"""`init_telegram_client` в lifespan и `get_telegram_client` в ручке — один и тот же объект."""
created = shared.init_telegram_client()
assert created is shared.get_telegram_client()
async def test_close_resets_and_next_get_builds_a_fresh_one() -> None:
"""После shutdown глобал обнулён; повторный startup обязан получить рабочий клиент.
Ленивость после закрытия намеренна: держать «остановленный» флаг значило бы,
что повторный `init_telegram_client()` в том же процессе отдаёт закрытый пул.
"""
first = shared.get_telegram_client()
await shared.close_telegram_client()
assert shared._client is None, "глобал не обнулён — следующий startup взял бы закрытый пул"
second = shared.get_telegram_client()
assert second is not first
async def test_close_is_idempotent() -> None:
"""Второй `close` не должен падать: lifespan зовёт его из `finally`."""
shared.get_telegram_client()
await shared.close_telegram_client()
await shared.close_telegram_client()
assert shared._client is None
async def test_close_without_client_is_a_noop() -> None:
"""Пустой токен — клиента в lifespan не создавали, закрывать нечего."""
assert shared._client is None
await shared.close_telegram_client()
assert shared._client is None

View file

@ -0,0 +1,436 @@
"""Тесты для #3471: startup-проверка темы форума + общий (per-группа) rate limit.
Оба сюжета выбраны так, чтобы падать на коде ДО правки:
- `TelegramGroupRateLimiter` и `verify_chat_and_topic` физически не существовали
в `app.services.tgbot.client` импорт ниже сам по себе даёт `ImportError` на
старом коде (подтверждено откатом при разработке).
- `test_send_message_rate_limits_across_different_topics_same_chat` без лимитера
прошёл бы «слишком хорошо» (`sleep_calls == []` после 3 отправок) это и есть
баг #3471 (нет лимита вовсе, всплеск бьёт по площадочному 429).
NEVER calls real Telegram API httpx.MockTransport only, тот же паттерн, что
`test_client.py`.
"""
from __future__ import annotations
import json
import os
from unittest import mock
import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.tgbot.client import (
TelegramApiError,
TelegramClient,
TelegramGroupRateLimiter,
TelegramRateLimitedError,
verify_chat_and_topic,
)
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _install_transport(handler) -> None:
transport = httpx.MockTransport(handler)
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
@pytest.fixture(autouse=True)
def _stop_patches():
yield
mock.patch.stopall()
# ── verify_chat_and_topic ────────────────────────────────────────────────────
async def test_verify_chat_and_topic_success_uses_send_message_and_delete_message() -> None:
"""Happy path: getChat + зондирующий sendMessage в тему + его deleteMessage.
`sendChatAction` тут больше не используется: живой прод-замер (#3471)
показал, что она подтверждает даже несуществующий `message_thread_id`
(`{"ok":true,"result":true}`) проверка была ложно-зелёной."""
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
method = request.url.path.rsplit("/", 1)[-1]
calls.append(method)
if method == "sendMessage":
return httpx.Response(200, json={"ok": True, "result": {"message_id": 42}})
return httpx.Response(200, json={"ok": True, "result": True})
_install_transport(handler)
client = TelegramClient(token="fake-token")
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
assert ok is True
assert calls == ["getChat", "sendMessage", "deleteMessage"]
async def test_verify_chat_and_topic_deletes_probe_message_leaving_no_trace() -> None:
"""Зонд `sendMessage` не остаётся видимым — `deleteMessage` бьёт по тому же
`message_id`, что вернул `sendMessage` (не мусорим в истории треда)."""
delete_payloads: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
method = request.url.path.rsplit("/", 1)[-1]
if method == "sendMessage":
return httpx.Response(200, json={"ok": True, "result": {"message_id": 986}})
if method == "deleteMessage":
delete_payloads.append(json.loads(request.content))
return httpx.Response(200, json={"ok": True, "result": True})
return httpx.Response(200, json={"ok": True, "result": {}})
_install_transport(handler)
client = TelegramClient(token="fake-token")
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
assert ok is True
assert len(delete_payloads) == 1
assert delete_payloads[0]["message_id"] == 986
async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None:
"""Тема удалена/переименована → Telegram отвечает отказом. Лог error, процесс жив."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
400, json={"ok": False, "error_code": 400, "description": "message thread not found"}
)
_install_transport(handler)
client = TelegramClient(token="fake-token")
with caplog.at_level("ERROR", logger="app.services.tgbot.client"):
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=999, label="support")
assert ok is False # не бросает исключение наружу — сигнализирует возвратом
assert any(record.levelname == "ERROR" for record in caplog.records)
assert any("support" in record.message for record in caplog.records)
async def test_verify_chat_and_topic_raises_nothing_even_on_network_error() -> None:
"""Сетевой сбой при проверке (не 4xx, а обрыв) — тоже не должен ронять старт."""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom", request=request)
_install_transport(handler)
with mock.patch("app.services.tgbot.client.asyncio.sleep", return_value=None):
client = TelegramClient(token="fake-token")
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="alerts")
assert ok is False
async def test_verify_chat_and_topic_skips_api_call_when_chat_id_is_zero() -> None:
"""chat_id=0 — штатный kill-switch (не настроено), не ошибка. Нет вызовов к API."""
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return httpx.Response(200, json={"ok": True, "result": True})
_install_transport(handler)
client = TelegramClient(token="fake-token")
ok = await verify_chat_and_topic(client, chat_id=0, topic_id=0, label="alerts")
assert ok is True
assert calls == []
async def test_verify_chat_and_topic_is_red_when_topic_deleted_even_though_send_chat_action_lies(
caplog,
) -> None:
"""Регрессия #3471: удалённая/переименованная тема ДОЛЖНА красить проверку.
Handler воспроизводит РЕАЛЬНО замеренное на проде поведение Bot API:
`sendChatAction` подтверждает даже несуществующий `message_thread_id`
(`{"ok":true,"result":true}` живой прод-замер, #3471), а `sendMessage`
с тем же id честно отвечает `400 Bad Request: message thread not found`.
На коде ДО правки (`verify_chat_and_topic` звала `send_chat_action`) этот
тест падает: `ok` был бы `True` проверка молчаливо считала мёртвую тему
живой. Явно проверено при разработке (#3471): `git checkout HEAD --
.../client.py` (до-фиксная версия) + прогон только этого теста дал
`assert True is False`; после возврата фикса зелёный."""
def handler(request: httpx.Request) -> httpx.Response:
method = request.url.path.rsplit("/", 1)[-1]
if method == "getChat":
return httpx.Response(200, json={"ok": True, "result": {"id": -100123}})
if method == "sendChatAction":
# Реальный прод-баг: sendChatAction НЕ валидирует message_thread_id.
return httpx.Response(200, json={"ok": True, "result": True})
if method == "sendMessage":
return httpx.Response(
400,
json={
"ok": False,
"error_code": 400,
"description": "Bad Request: message thread not found",
},
)
return httpx.Response(200, json={"ok": True, "result": True})
_install_transport(handler)
client = TelegramClient(token="fake-token")
with caplog.at_level("ERROR", logger="app.services.tgbot.client"):
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=987654, label="support")
assert ok is False
assert any(record.levelname == "ERROR" for record in caplog.records)
# ── TelegramGroupRateLimiter ─────────────────────────────────────────────────
async def test_rate_limiter_allows_calls_up_to_limit_without_waiting() -> None:
sleep_calls: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
with mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep):
limiter = TelegramGroupRateLimiter(max_per_window=3, window_s=60.0)
await limiter.acquire(chat_id=555)
await limiter.acquire(chat_id=555)
await limiter.acquire(chat_id=555)
assert sleep_calls == []
async def test_rate_limiter_waits_once_limit_exceeded_same_chat() -> None:
"""4-я отправка в тот же chat_id за лимитом (2) — ждёт, а не теряется/падает."""
sleep_calls: list[float] = []
fake_now = [1_000.0]
def fake_monotonic() -> float:
return fake_now[0]
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
fake_now[0] += seconds
with (
mock.patch("app.services.tgbot.client.time.monotonic", fake_monotonic),
mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep),
):
limiter = TelegramGroupRateLimiter(max_per_window=2, window_s=60.0)
await limiter.acquire(chat_id=1)
await limiter.acquire(chat_id=1)
assert sleep_calls == []
await limiter.acquire(chat_id=1) # третья — сверх лимита
assert sleep_calls # ждала, а не пролетела/бросила исключение
assert sleep_calls[0] > 0
async def test_rate_limiter_different_chats_do_not_block_each_other() -> None:
"""Разные группы (chat_id) — независимые бюджеты, одна не душит другую."""
sleep_calls: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
with mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep):
limiter = TelegramGroupRateLimiter(max_per_window=1, window_s=60.0)
await limiter.acquire(chat_id=1)
await limiter.acquire(chat_id=2) # другая группа — свой бюджет
assert sleep_calls == []
async def test_rate_limiter_no_races_under_concurrent_senders() -> None:
"""Несколько конкурентных asyncio-отправителей в ОДИН chat_id — без гонок:
итоговое число «пропущенных без ожидания» строго не больше лимита, и никто
не теряется/не падает все 5 корутин успешно завершаются."""
import asyncio
limiter = TelegramGroupRateLimiter(max_per_window=2, window_s=0.15)
async def sender() -> None:
await limiter.acquire(chat_id=42)
# Реальное время, реальный event loop — единственный способ честно
# проверить отсутствие гонки на разделяемом `self._sent_at[chat_id]`.
results = await asyncio.gather(*(sender() for _ in range(5)), return_exceptions=True)
assert all(not isinstance(r, Exception) for r in results)
history = limiter._sent_at[42]
# После завершения всех 5 — в окне НЕ больше max_per_window меток (иначе
# гонка позволила бы двум конкурентным acquire() одновременно "проскочить").
assert len(history) <= 2
# ── интеграция с TelegramClient.send_message ─────────────────────────────────
async def test_send_message_rate_limits_across_different_topics_same_chat() -> None:
"""Ключевой сценарий #3471: РАЗНЫЕ темы одной группы делят ОДИН бюджет.
Без правки лимитера нет вовсе этот тест на старом коде проходил бы
"слишком хорошо" (sleep не звался никогда), что и есть баг.
ВАЖНО (post-merge review, #3494 CI hang): `fake_sleep` ОБЯЗАН двигать
подменённый `time.monotonic` вперёд, а не оставаться чистым no-op. Без
этого 3-я отправка (сверх лимита=2) уходит в `acquire()`, `wait_s`
пересчитывается из РЕАЛЬНОГО `time.monotonic()` (не замоканного здесь),
окно не истекает и `while True` крутится настоящим busy-spin БЕЗ
единого реального ожидания, пока `pytest-timeout` не убьёт тест десятками
секунд спустя. Именно так этот тест сам стал причиной 75-секундного
зависания CI-джобы (обрыв на ~9%, ни строки об ошибке) не сам лимитер и
не синглтон `shared.py` (гипотеза координатора была разумной, но к этому
конкретному зависанию отношения не имела).
"""
sleep_calls: list[float] = []
fake_now = [0.0]
def fake_monotonic() -> float:
return fake_now[0]
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
fake_now[0] += seconds
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
_install_transport(handler)
with (
mock.patch("app.services.tgbot.client.time.monotonic", fake_monotonic),
mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep),
):
client = TelegramClient(token="fake-token", group_rate_limit_per_minute=2)
await client.send_message(chat_id=42, text="a", message_thread_id=1) # тема поддержки
await client.send_message(chat_id=42, text="b", message_thread_id=2) # тема алертов
assert sleep_calls == []
await client.send_message(chat_id=42, text="c", message_thread_id=3) # 3-я тема, тот же чат
assert sleep_calls # общий бюджет группы исчерпан третьей отправкой
async def test_send_message_does_not_rate_limit_missing_chat_id_payload() -> None:
"""get_updates (нет chat_id в payload) не должен спотыкаться о лимитер вовсе."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": []})
_install_transport(handler)
client = TelegramClient(token="fake-token", group_rate_limit_per_minute=1)
# Не должно ни зависнуть, ни бросить — getUpdates не несёт chat_id.
result = await client.get_updates(offset=1)
assert result == []
def test_telegram_api_error_importable_for_manual_inspection() -> None:
"""Sanity: тестовый модуль не потерял импорт TelegramApiError (использовался
при отладке 400-ответа выше)."""
assert issubclass(TelegramApiError, Exception)
# ── review H1: честный отказ вместо бесконечного ожидания на interactive-пути ─
async def test_rate_limiter_raises_rate_limited_error_when_max_wait_exceeded() -> None:
"""Слот не появился за `max_wait` — `TelegramRateLimitedError`, не вечный сон."""
fake_now = [0.0]
def fake_monotonic() -> float:
return fake_now[0]
async def fake_sleep(seconds: float) -> None:
fake_now[0] += seconds
with (
mock.patch("app.services.tgbot.client.time.monotonic", fake_monotonic),
mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep),
):
limiter = TelegramGroupRateLimiter(max_per_window=1, window_s=60.0)
await limiter.acquire(chat_id=9) # занимает единственный слот
with pytest.raises(TelegramRateLimitedError):
await limiter.acquire(chat_id=9, max_wait=2.0) # бюджет короче окна
async def test_send_message_bounds_rate_limit_wait_by_explicit_timeout() -> None:
"""Явный `timeout` (как у interactive-ручек support.py/glitchtip.py) сам по
себе ограничивает ожидание очереди без правки самих ручек (review H1)."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
_install_transport(handler)
with mock.patch(
"app.services.tgbot.client.TelegramGroupRateLimiter.acquire",
autospec=True,
) as acquire_mock:
client = TelegramClient(token="fake-token")
await client.send_message(chat_id=1, text="hi", timeout=3.0)
_, kwargs = acquire_mock.call_args
assert kwargs.get("max_wait") == 3.0
async def test_send_message_unbounded_wait_by_default_for_background_calls() -> None:
"""Без явного `timeout` (типичный фоновый вызов) — `max_wait=None` (без потолка)."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
_install_transport(handler)
with mock.patch(
"app.services.tgbot.client.TelegramGroupRateLimiter.acquire",
autospec=True,
) as acquire_mock:
client = TelegramClient(token="fake-token")
await client.send_message(chat_id=1, text="hi")
_, kwargs = acquire_mock.call_args
assert kwargs.get("max_wait") is None
# ── review H2: бюджет группы разделён по ролям (API-процесс / бот-процесс) ────
def test_group_rate_limit_split_by_role_sums_below_platform_ceiling() -> None:
"""API- и бот-роль вместе НЕ должны превышать площадочный лимит (~20/мин).
Регрессия ровно на баг из review H2: раньше оба процесса получали
ОДИНАКОВЫЙ дефолт (18+18=36) сумма вдвое превышала лимит площадки.
"""
from app.core.config import settings
api_limit = settings.telegram_group_rate_limit_api_per_minute
bot_limit = settings.telegram_group_rate_limit_bot_per_minute
assert api_limit > 0
assert bot_limit > 0
assert api_limit + bot_limit < 20
def test_shared_client_uses_api_role_limit() -> None:
"""`app.services.tgbot.shared` (процесс uvicorn) собирает клиент с API-долей."""
from app.core.config import settings
from app.services.tgbot import shared
shared._client = None
try:
client = shared.get_telegram_client()
assert (
client._rate_limiter._max_per_window
== settings.telegram_group_rate_limit_api_per_minute
)
finally:
shared._client = None

View file

@ -38,6 +38,7 @@ tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
tests/test_user_events.py::test_real_record_event_inserts_row
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
@ -129,3 +130,18 @@ tests/test_revisit_floor_lateral_lookup.py::test_missing_history_before_anchor_s
# (postgres-сервис) — там прогон и был зелёным; в deploy-tradein.yml БД нет вовсе.
tests/test_3063_seller_fields_not_eroded.py::test_poor_rescrape_does_not_erase_seller_fields
tests/test_3063_seller_fields_not_eroded.py::test_real_change_still_overwrites
# Потолки БД на движке (#3463) — поведенческая половина: проверяют, что запрос
# длиннее потолка ОБРЫВАЕТСЯ (pg_sleep), что ожидание блокировки отваливается по
# lock_timeout, и что `SET LOCAL` задачи планировщика перекрывает сессионный
# потолок и не течёт за свою транзакцию. Всё это нельзя проверить без сервера:
# потолок применяет Postgres, а не питон. Статическая половина того же файла
# (test_engine_opens_connections_with_both_ceilings +
# test_ceilings_are_coherent_with_declared_estimate_budgets) идёт на ОБОИХ лэйнах
# и краснеет от снятия `connect_args` — проверено вручную 12.09. В ci-tradein.yml
# эти пять бегут по-настоящему (postgres-сервис, #2745).
tests/test_3463_db_timeouts.py::test_live_session_reports_both_ceilings
tests/test_3463_db_timeouts.py::test_statement_over_ceiling_is_cancelled_not_hung
tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted
tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction

View file

@ -348,7 +348,9 @@ async def test_backfill_abort_log_has_no_ip_rate_limited_literal(caplog: Any) ->
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
caplog.at_level("ERROR"),
# серия подтверждённых блоков площадки -- штатный исход скрапинга, не
# инцидент; понижено до warning, чтобы не шуметь в GlitchTip.
caplog.at_level("WARNING"),
):
await run_avito_detail_backfill(
db, run_id=3, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5}
@ -1354,7 +1356,9 @@ async def test_backfill_ratio_abort_log_names_the_ratio_not_the_streak(caplog: A
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
caplog.at_level("ERROR"),
# ABORT по доле блоков -- штатный исход скрапинга, не инцидент; понижено
# до warning, чтобы не шуметь в GlitchTip.
caplog.at_level("WARNING"),
):
result = await run_avito_detail_backfill(
db, run_id=110, params={"batch_size": total, "budget_sec": 3600}
@ -1396,7 +1400,9 @@ async def test_backfill_safety_net_abort_log_names_the_streak(caplog: Any) -> No
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
caplog.at_level("ERROR"),
# ABORT по safety-net серии -- штатный исход скрапинга, не инцидент;
# понижено до warning, чтобы не шуметь в GlitchTip.
caplog.at_level("WARNING"),
):
result = await run_avito_detail_backfill(
db, run_id=111, params={"batch_size": total, "budget_sec": 3600}

View file

@ -287,7 +287,9 @@ async def test_backfill_abort_log_has_no_qrator_literal(caplog: pytest.LogCaptur
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
caplog.at_level("ERROR"),
# серия подтверждённых блоков площадки -- штатный исход скрапинга, не
# инцидент; понижено до warning, чтобы не шуметь в GlitchTip.
caplog.at_level("WARNING"),
):
await run_domclick_detail_backfill(
db,

View file

@ -65,7 +65,9 @@ async def test_backfill_browser_fetcher_gets_proxy_pool_wiring(
db.execute.return_value.mappings.return_value.all.return_value = [
{"id": 1, "address": "ЕКБ, ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}
]
await hib.backfill_house_imv(db, batch_size=1)
# request_delay_sec=0: мок дублирует строку в retry+fresh (2 записи вместо 1) — реальный
# анти-бот sleep(5с) между ними тут не нужен.
await hib.backfill_house_imv(db, batch_size=1, request_delay_sec=0)
captured = _CapturingFetcher.captured
assert captured["source"] == "avito"

View file

@ -101,6 +101,12 @@ async def _run(pending: list[dict], *, force: bool = False, resolved: str | None
),
patch.object(mod, "_sleep_with_jitter", AsyncMock(return_value=None)),
patch("app.services.scraper_adapters.RealScraperConfig", MagicMock()),
# Удачный резолв доходит до fetch_jk — без мока это реальный BrowserFetcher/прокси-пул
# (26с на живом прогоне); тесту нужен только факт «маркер не выставлен», не сам fetch.
patch(
"scraper_kit.providers.yandex.newbuilding.YandexNewbuildingScraper.fetch_jk",
AsyncMock(return_value=None),
),
):
await mod.enrich_yandex_newbuilding_sweep(
db, city="ekaterinburg", limit=5, force=force, request_delay_sec=0

View file

@ -129,7 +129,11 @@ def _capture(method: str, **kw: object) -> list[str]:
def test_anchor_sweep_uses_secondary_slug_by_default() -> None:
"""fetch_around по умолчанию — путь вторички, geoCoords/radius сохранены в query."""
urls = _capture("fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1)
# delay_override_sec=0: сеть подменена, anti-ban sleep(5с*jitter) после единственной
# страницы тут не защищает ничего реального.
urls = _capture(
"fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1, delay_override_sec=0
)
assert urls, "fetch_around не сделал запроса"
u = urlparse(urls[0])
assert u.path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}", u.path
@ -139,8 +143,15 @@ def test_anchor_sweep_uses_secondary_slug_by_default() -> None:
def test_anchor_sweep_secondary_only_false_keeps_general_path() -> None:
"""Контроль: secondary_only=False — прежняя общая выдача."""
# delay_override_sec=0: тот же anti-ban trailing sleep, что и в тесте выше.
urls = _capture(
"fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1, secondary_only=False
"fetch_around",
lat=56.84,
lon=60.6,
radius_m=1000,
pages=1,
secondary_only=False,
delay_override_sec=0,
)
assert urlparse(urls[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ"

View file

@ -283,11 +283,15 @@ def _fetch(
) -> list[dict[str, Any]]:
est._city_price_bands_cache = None
try:
# `rooms` у `_fetch_deals` больше нет: #3256 убрал параметр, а не просто
# перестал класть его в SQL — сделки Росреестра комнатность не несут.
# Тест писался на ветке, отведённой до того тикета, и разъехался с main
# молча: текстового конфликта в мерже не было, красным стало только на
# post-merge прогоне (#3440).
return est._fetch_deals(
_db(rows, _BANDS_ROWS if band_rows is None else band_rows),
lat=55.74,
lon=37.6,
rooms=2,
area=80.0,
radius_m=1000,
)

Some files were not shown because too many files have changed in this diff Show more