Compare commits

...
Sign in to create a new pull request.

271 commits

Author SHA1 Message Date
9b818c2029 Merge pull request 'Деплой МЕРЫ снова заблокирован: три живых теста снапшотов не в allowlist' (#3584) from fix/allowlist-2993-live-tests into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Successful in 51s
Deploy Trade-In / build-frontend (push) Successful in 2m57s
Deploy Trade-In / test (push) Successful in 4m24s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Successful in 2m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-17 11:35:34 +00:00
fd454dec4b fix(tests): красный деплой-лэйн — три живых теста #2993 не объявлены в skip_allowlist
All checks were successful
CI / changes (pull_request) Successful in 19s
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 5m47s
CI / frontend-tests (pull_request) Has been skipped
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
После мержа #3577 (снапшоты «строка на изменение») прогон без БД — тот, что
гоняет deploy-tradein.yml перед сборкой, — завершается rc=1:

  НЕУЧТЁННЫЙ ПРОПУСК (3): tests/test_2993_snapshot_change_only.py::...

В CI PR эти тесты шли против postgres-сервиса и проходили, поэтому
расхождение видно только на деплой-лэйне, где они законно пропускаются.
Объявлены с причиной, как соседние живые тесты (#3385, #3191).

Деплой-лэйн: 6465 passed, 68 skipped, rc=0, неучтённых пропусков 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:28:59 +05:00
7da94f183f Merge pull request 'Тесты разбора площадок: дом Авито, выдачи Циана и Яндекса снова проверяются на сохранённых страницах' (#3580) from fix/kit-parser-unit-tests into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / build-browser (push) Successful in 2m43s
Deploy Trade-In / build-frontend (push) Successful in 3m15s
Deploy Trade-In / test (push) Failing after 4m34s
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-17 11:23:32 +00:00
9e337855da Merge pull request 'Сайдкар МЕРЫ: не больше трёх живых браузеров, простаивающий закрывается, зомби-процессы собирает init' (#3581) from fix/browser-instances-limit 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-17 11:23:14 +00:00
5b6be30894 Merge pull request 'Снимки источников МЕРА пишутся только при изменении — в 26–38 раз меньше строк за ночь (#2993, PR-C)' (#3577) from fix/snapshot-change-only 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-17 11:23:00 +00:00
71e551d57d Merge pull request 'МЕРА: срок продажи показываем окном «от–до» по снятым объявлениям и сохраняем его вместе с оценкой' (#3573) from fix/days-on-market-window into main
Some checks failed
Deploy Trade-In / changes (push) Has been cancelled
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
2026-09-17 11:22:45 +00:00
a6a1b7cdfc Merge pull request 'Бэктест оценщика берёт тип дома из справочника домов, как боевая оценка — штраф за тип дома впервые участвует в замере' (#3571) from fix/backtest-house-type 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-17 11:22:33 +00:00
9c71349eed Merge pull request 'МЕРА, область: коридор сделок узнаёт города развёрток, ГАР не раздаёт домам чужой регион, история цен не врёт про «только этот дом»' (#3575) from fix/region-audit-tail into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / build-frontend (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-17 11:22:16 +00:00
4c11a7dab5 Merge pull request 'Яндекс: пять вечно недогружаемых карточек больше не обрывают каждый прогон добора' (#3574) from fix/yandex-perpetual-underloaded 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-17 11:22:03 +00:00
f9bebdf40f Merge pull request 'Схлопывание дублей домов: ФИАС-проход больше не сливает дома из разных городов, а «17Ар-н Академический» находит своего двойника' (#3558) from fix/houses-dedup into main
Some checks failed
Deploy Trade-In / test (push) Has been cancelled
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 31s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-09-17 11:17:35 +00:00
e942acaf12 Merge pull request 'Конкуренты ПТИЦЫ: скорость продаж и цена для несопоставленных ЖК берутся по своему проекту, а не по чужим под общим complex_id' (#3582) from fix/competitors-bridge into main
All checks were successful
Deploy / deploy (push) Successful in 1m30s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m49s
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m51s
Deploy / build-worker (push) Successful in 4m10s
2026-09-17 11:16:09 +00:00
3a8513cd68 Merge pull request 'ПТИЦА: убрать мёртвую фазу bulk_harvest, которая на каждый квартал запрашивала ПЗЗ у НСПД и писала их в таблицу без читателей' (#3579) from fix/ptica-bulk-harvest-phase25-removal into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m51s
Deploy / build-worker (push) Has been cancelled
2026-09-17 11:11:54 +00:00
77fdeefc50 Merge pull request 'Лучшие планировки: в «покрытие P% (Y из N комплексов)» N считается одинаково во всех ветках' (#3570) from fix/ptica-best-layouts into main
Some checks failed
Deploy / changes (push) Successful in 24s
Deploy / build-backend (push) Has been cancelled
Deploy / build-frontend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / deploy-caddy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
2026-09-17 11:02:13 +00:00
6eaa31ac66 Merge remote-tracking branch 'origin/main' into fix/ptica-bulk-harvest-phase25-removal
All checks were successful
CI Trade-In / changes (pull_request) Successful in 26s
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
2026-09-17 15:50:38 +05:00
5af29ee9a4 Merge remote-tracking branch 'origin/main' into fix/kit-parser-unit-tests
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 27s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 33s
CI Trade-In / backend-tests (pull_request) Successful in 7m9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
2026-09-17 15:50:27 +05:00
92af3491ae Merge remote-tracking branch 'origin/main' into fix/browser-instances-limit
All checks were successful
CI Trade-In / changes (pull_request) Successful in 29s
CI / changes (pull_request) Successful in 33s
CI Trade-In / backend-tests (pull_request) Successful in 7m23s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 2m11s
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
2026-09-17 15:50:18 +05:00
d76411b45f Merge remote-tracking branch 'origin/main' into fix/competitors-bridge
All checks were successful
CI / backend-tests (pull_request) Successful in 10m3s
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 3m4s
CI Trade-In / changes (pull_request) Successful in 21s
CI / openapi-codegen-check (pull_request) Successful in 4m23s
CI / changes (pull_request) Successful in 25s
2026-09-17 15:50:07 +05:00
c14119a20b Merge remote-tracking branch 'origin/main' into fix/snapshot-change-only
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 8m31s
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 / changes (pull_request) Successful in 28s
CI / changes (pull_request) Successful in 31s
2026-09-17 15:49:56 +05:00
084cb79f99 Merge remote-tracking branch 'origin/main' into fix/days-on-market-window
All checks were successful
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 3m13s
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 35s
CI Trade-In / backend-tests (pull_request) Successful in 8m46s
2026-09-17 15:49:46 +05:00
86629dc9f4 Merge remote-tracking branch 'origin/main' into fix/backtest-house-type
All checks were successful
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 / changes (pull_request) Successful in 21s
CI / changes (pull_request) Successful in 33s
CI Trade-In / backend-tests (pull_request) Successful in 8m41s
2026-09-17 15:49:37 +05:00
372328b726 Merge remote-tracking branch 'origin/main' into fix/region-audit-tail
All checks were successful
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 24s
CI Trade-In / frontend-checks (pull_request) Successful in 3m19s
CI / changes (pull_request) Successful in 28s
CI Trade-In / backend-tests (pull_request) Successful in 8m48s
CI Trade-In / browser-tests (pull_request) Has been skipped
2026-09-17 15:49:27 +05:00
6375b191e4 Merge remote-tracking branch 'origin/main' into fix/yandex-perpetual-underloaded
All checks were successful
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 19s
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 / changes (pull_request) Successful in 23s
CI Trade-In / backend-tests (pull_request) Successful in 8m29s
2026-09-17 15:49:09 +05:00
2b73961af5 Merge pull request 'Пул прокси МЕРА: оплаченный выделенный узел снова выдаётся, бан не выбивает последний узел, прогон знает свой прокси' (#3565) from fix/proxy-pool-guards into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 2m5s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy-status (push) Successful in 4s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m52s
Deploy Trade-In / build-backend (push) Successful in 3m17s
Deploy Trade-In / changes (push) Successful in 34s
Deploy Trade-In / test (push) Successful in 6m22s
2026-09-17 10:49:03 +00:00
cd504c1eb9 Merge remote-tracking branch 'origin/main' into fix/ptica-best-layouts
All checks were successful
CI / backend-tests (pull_request) Successful in 10m16s
CI Trade-In / changes (pull_request) Successful in 20s
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
CI / openapi-codegen-check (pull_request) Successful in 4m13s
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
2026-09-17 15:49:02 +05:00
f9045673e1 Merge remote-tracking branch 'origin/main' into fix/houses-dedup
All checks were successful
CI Trade-In / changes (pull_request) Successful in 18s
CI / changes (pull_request) Successful in 24s
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 8m37s
2026-09-17 15:48:56 +05:00
4ccf701df6 merge origin/main: конфликт skip_allowlist.txt (оба блока сохранены)
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 7m45s
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
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
Полный сьют после слияния: 6378 passed, 61 skipped, rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:48:25 +05:00
21fe24419a ПТИЦА конкуренты: у complex с двумя objective-проектами берётся сверенный по имени, тесты на сверку в обе стороны, кавычки и #968 (#2962)
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 15s
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 1m45s
CI / openapi-codegen-check (pull_request) Successful in 2m50s
CI / backend-tests (pull_request) Successful in 7m14s
По ревью PR #3582. Три мутации тесты не ловили (5 passed): нормализация
только пробелов, односторонний LIKE, снятый EXISTS #968. Плюс латентный
дефект: UNIQUE(source, source_id) не запрещает complex иметь два
objective-проекта, DISTINCT ON брал любой, сверка его отвергала, и верный
проект терялся (воспроизведено: complex с «Клён» и «Сосны», брался «Клён»).

- nearest_cx в обоих SQL: нормализованные ключи в LATERAL, сверка name_ok
  считается там же и стоит в ORDER BY после расстояния (name_ok DESC,
  source_id) — при двух проектах берётся сверенный, выбор детерминирован.
  Фильтр по-прежнему ПОСЛЕ DISTINCT ON.
- тесты: «Квартал "Татлин"» = «Квартал Татлин» (кавычки посреди имени),
  «Парковый» ⊂ «Парковый квартал» (обратная сторона LIKE), ближайший
  complex без лотов не съедает матч (#968), complex с двумя проектами.
  Старый тест пунктуации проверял пробел — переименован честно.

Прод 17.09 (только чтение): выбор nearest_cx у всех 185 gap-fill объектов
в обоих SQL совпал с головой PR (0 расхождений, 176 принято, 9 отвергнуто);
время на центре ЕКБ 1 км: конкуренты 70 → 69 мс, цена 103 → 103 мс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:33:25 +05:00
33182e7df9 CI ПТИЦА: Postgres с PostGIS, тест моста gap-fill в CI исполняется, а не пропускается (#2962)
На голове b74b02ea CI / backend-tests был красным (run 11737): 5125 passed, но
все 5 тестов test_2962_competitors_gapfill_bridge.py пропустились с причиной
«нет расширения postgis», а в skip_allowlist.txt их не было — гейт вернул rc=1.
В CI ПТИЦЫ plain postgres:16, а SQL конкурентов без PostGIS не исполнить
(ST_DWithin по geography).

- ci.yml: образ postgis/postgis:16-3.4 (тот же, что в ci-tradein.yml); образ
  сам создаёт расширение в POSTGRES_DB.
- тест: при CI/GITHUB_ACTIONS skipif выключен — если PostGIS из CI пропадёт,
  тест упадёт с настоящей причиной, а не пропустится молча.
- skip_allowlist.txt: пропуск объявлен только для машины без базы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:28:19 +05:00
89b4a67508 Merge remote-tracking branch 'origin/main' into fix/ptica-bulk-harvest-phase25-removal
All checks were successful
CI Trade-In / 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 / changes (pull_request) Successful in 14s
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 2m42s
CI / backend-tests (pull_request) Successful in 7m11s
2026-09-17 15:22:02 +05:00
8def34028c ПТИЦА: комментарии тестов bulk_harvest больше не упоминают удалённую фазу ПЗЗ (#2985)
В шести тестах test_cadastre_bulk.py комментарий к моку с xmin=None
говорил, что пропускаются «grid-walk + territorial_zones фазы». Фазы
territorial_zones в harvest_quarter после предыдущего коммита нет;
при bbox=None пропускается только grid-walk (bulk_harvest.py:176, :335).

Правка только в комментариях, поведение тестов не меняется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:21:54 +05:00
b74b02ea8b ПТИЦА конкуренты: gap-fill берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 29s
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 28s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m52s
CI / backend-tests (pull_request) Failing after 8m27s
Что было. Для конкурентов вне objective_complex_mapping мост шёл
«ближайший complex → objective_lots.complex_id → все project_name».
complex_id проставлен один раз миграцией 76 на загрузке 10.05, а
еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
переписывает project_name и не трогает complex_id. Прод 17.09: из
303 677 строк с complex_id у 236 354 проект чужой (все вставлены 10.05,
переписаны 17.05–15.09). У 185 gap-fill конкурентов своих лотов 23 %,
скорость в медиане завышена в 39 раз (сумма 65 798 против 1 609 сделок/мес).

Что сделано. В _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL complex
связывается с проектом через complex_sources (source='objective', 1:1),
лоты и сделки берутся по project_name. Связь в complex_sources почти вся
fuzzy и не проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
'Гранит'), поэтому имя проекта дополнительно сверяется с именем объекта
ДОМ.РФ без регистра и пунктуации; сверка стоит после DISTINCT ON, внутри
join планировщик гонял regexp по 383k пар (3 с).

Замер на проде (все 1556 объектов, окно 3 мес): явный маппинг 308 и
остальные 1063 — изменилось 0; gap-fill 185 — изменились все, у 9 связь
отвергнута (7 чужих проектов + 2 «Традиции»/«Традиция»), доля своих лотов
23 % → 96 %. На 500 участках: 4317 прежних пар участок-конкурент — 0
изменений, 598 gap-fill пар — изменились все. Время запроса конкурентов
393 → 65 мс, ценового fallback 23 → 54 мс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:09:42 +05:00
be1403289a Merge pull request 'Домклик: число собственников снова читается, жилая площадь и балконы не стираются переобходом, нули в площадях — не площадь' (#3569) from fix/domclick-card-fields into main
All checks were successful
Deploy Trade-In / build-browser (push) Successful in 1m8s
Deploy Trade-In / build-frontend (push) Successful in 4m55s
Deploy Trade-In / test (push) Successful in 6m25s
Deploy Trade-In / build-backend (push) Successful in 3m22s
Deploy Trade-In / deploy (push) Successful in 3m13s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m53s
Deploy Trade-In / changes (push) Successful in 30s
2026-09-17 10:05:08 +00:00
30d40e5747 Сайдкар МЕРЫ: не больше трёх живых браузеров и никаких зомби-процессов (#3412)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 40s
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 / browser-tests (pull_request) Successful in 2m0s
CI Trade-In / backend-tests (pull_request) Successful in 8m5s
Инстанс camoufox поднимался на поставщика и не закрывался никогда: пять
поставщиков давали пять вечных браузеров в контейнере с mem_limit 2560m. Прод
15.09-17.09: 5 живых простояли 2.4 ч прямо перед резким рестартом 16.09 19:08;
медиана working_set 1494 МиБ при 3 живых, 1956 при 4, 2108 при 5.

Потолок BROWSER_MAX_INSTANCES (по умолчанию 3) проверяется в _launch_browser,
через который идут все запуски, включая фоновый retry. На потолке закрывается
простаивающий инстанс другого поставщика: без context'а первым, по давности
использования внутри класса. Занятый лок не трогается, если закрыть некого,
запуск идёт сверх потолка с предупреждением. Слот резервируется на время
запуска. _close_browser снимает инстанс со словарей до первого await и больше
не сбрасывает пейсинг: это делают только собственный relaunch и смена прокси,
как раньше. Четыре дефекта ревью ветки fix/3412-cian-instance-relaunch
закрыты тестами, каждый краснеет при снятии своей части фикса.

init: true у сервиса browser: PID 1 был python server.py и не собирал
осиротевших детей закрытого браузера; прод 17.09 — 2296 процессов в Z за 14 ч.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:04:34 +05:00
3ee5244491 ПТИЦА: убрать мёртвую фазу 2.5 bulk_harvest — запрос ПЗЗ в НСПД на каждый квартал (#2985)
All checks were successful
CI / openapi-codegen-check (pull_request) Successful in 4m1s
CI / backend-tests (pull_request) Successful in 10m2s
CI Trade-In / changes (pull_request) Successful in 23s
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 27s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
harvest_quarter после основного обхода делал отдельный grid-walk слоя 875838
(49 запросов к НСПД на квартал) и писал результат в cad_territorial_zones.
Эту таблицу никто не читает: на проде 1 строка за два полных прохода по ЕКБ,
zone_code/zone_name пустые (маппинг свойств не совпадает с ответом НСПД).
ПЗЗ до отчёта доходят другой трубой: nspd_sync -> nspd_quarter_dumps.features_json.
Владелец 17.09 выбрал удаление фазы.

Удалено: блок Phase 2.5 в harvest_quarter, писатель _save_territorial_zones,
обёртка NSPDBulkClient.get_territorial_zones_in_bbox (других вызовов нет),
тесты писателя и мок в test_cadastre_bulk. Таблица и её данные не тронуты,
миграций нет. Поправлены комментарии, ссылавшиеся на фазу.

Новый тест: квартал без overflow с валидным bbox стоит ровно один запрос
к НСПД (search_by_quarter), фазы прогресса без territorial_zones_started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:03:05 +05:00
6003ddedad Тесты kit-парсеров на сохранённых страницах: дом Авито, выдачи Циана и Яндекса (#2422, #2408)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 32s
CI / changes (pull_request) Successful in 33s
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 8m7s
После удаления golden-parity (#2419) разбор avito.houses, cian.serp и yandex.serp
не проверял ни один тест: правка извлечения поля в kit проходила бы зелёной.

- avito.houses: обе осиротевшие карточки дома (572538, 115188) — характеристики,
  рейтинг из reviews.filterEntries (#1789), отзывы, история размещений, мини-выдача
  с продавцом, рекомендации; карточка ЖК 12879380 — ветка developmentPage.
- avito.serp: цена, комнаты, площадь, этаж/этажность и дата из sortTimeStamp
  на avito_serp_sample.html.
- cian.serp: живая выдача 2026-09-17, урезанная до 4 offers и читаемых ключей
  (без телефонов и данных пользователя): вторичка, новостройка с домом, отделка
  и балкон, новостройка без улицы — номер дома не выдумывается (#1773).
- yandex.serp: живой gate-API 2026-09-17: канонизация партнёрского url (#2235),
  собственник/агентство, студия без roomsTotal, новостройка без оценки.
- guard: ни один модуль scraper_kit не импортирует app.* (AST, включая ленивые
  импорты) — замена удалённого test_kit_avito_has_no_app_imports.

Тесты лежат в tradein-mvp/backend/tests/scrapers, а не в packages/scraper-kit/tests:
CI гоняет pytest только из backend, каталог в пакете не собирался бы никем.

Мутационный прогон: 9 порч kit (рейтинг #1789, developmentPage, цена истории,
этаж Авито, год сдачи и адрес Циана, url и студия Яндекса, import app) — каждая
роняет хотя бы один тест.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:02:31 +05:00
bdc169af8b merge origin/main: починка красного main (#3572)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 26s
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 7m47s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:56:10 +05:00
7350287be7 merge origin/main: починка красного main (#3572)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 21s
CI / changes (pull_request) Successful in 26s
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 7m54s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:56:03 +05:00
85d455e387 Merge pull request 'Красный main: тесты коридора читали настройки, которые стали константами' (#3572) from fix/main-red-corridor-settings-tests 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 Trade-In / changes (push) Successful in 31s
Deploy Trade-In / build-browser (push) Successful in 1m10s
Deploy Trade-In / build-frontend (push) Successful in 4m40s
Deploy Trade-In / test (push) Successful in 5m52s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-09-17 09:55:42 +00:00
e4d44025e5 fix(tradein/snapshot): снимок источника пишется только при изменении (#2993, PR-C)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 26s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 31s
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 7m37s
listing_source_snapshot каждую ночь копировал listing_sources целиком: прод
14-17.09 — 290-293 тыс. строк в сутки, из них отличались от предыдущего
снимка 7.7-11.4 тыс. (3-4 %). Решение владельца 2026-08-23 — строка на
изменение; читатель (пол переобхода, PR-A #3056) и рельсы объёма (PR-B
#3066) уже смержены.

Строка пишется, если снимка ещё нет или (price_rub, is_active, last_seen_at,
payload_hash) отличается от последнего снимка источника, включая сегодняшний
(повторный прогон в те же сутки перезаписывает строку, только если значение
снова сдвинулось). Состояние на дату D = последняя строка с snapshot_date <= D
— для всех четырёх колонок то же, что дала бы суточная копия, поэтому пол
переобхода и события не меняются. Старые суточные снимки не трогаются.

Замер чтением на проде 17.09: отбор с условием — 1.0 с на 293 602 источника
(бюджет 900 с). Миграция 325 — только COMMENT на таблицу и view: прежние
«ежедневный снимок»/«на дату снимка» стали бы неправдой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:46:36 +05:00
635381f7b8 fix(tests): красный main — тесты #3466 читали настройки, которые #3556 перевёл в константы
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 8m23s
CI Trade-In / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 20s
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
Семантический конфликт двух PR, смерженных 17.09 подряд. Текстового конфликта не
было, CI каждого PR был зелёным на своей голове, а объединённое дерево падало:

  test_3466_corridor_tier_a.py (2), test_estimator_radius_floor.py (2)
  AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'
  TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor'

#3554 (Tier A и advisory_only) писал тесты против settings.estimate_corridor_clamp_*,
а #3556 (#2380) убрал эти поля из Settings в константы CORRIDOR_CLAMP_SLACK
(estimator) и CORRIDOR_CLAMP_MIN_N (app.core.config) и снял параметр
radius_floor_factor у хелпера. Продуктовый код не затронут: в app/ старые имена
остались только в комментарии, на проде AttributeError не было. Но красный test
блокировал деплой МЕРЫ: на проде до сих пор образ до второй пачки мержей.

Тесты переведены на константы, значения прежние (0.40, 10, 0.8).
pytest tests/ — 6374 passed, 44 skipped, rc=0; ruff check/format — rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:45:54 +05:00
e3616e3611 Слить свежий origin/main в fix/houses-dedup (вместо rebase: ветка уже опубликована, push без --force)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m21s
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:45:03 +05:00
3b2f5ed642 МЕРА: срок экспозиции окном p25–p75 по снятым объявлениям и хранится с оценкой (#2898)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m32s
CI Trade-In / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 25s
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 3m18s
Было: est_days_on_market — одно число, медиана days_on_market активных
аналогов (возраст висящего объявления, цензурированная выборка), в
trade_in_estimates не сохранялось, GET по ссылке отдавал null.

Стало:
- эстиматор берёт exposure_days из house_placement_history: те же комнаты,
  площадь ±15%, дома в радиусе подбора аналогов, снятые за 24 мес.;
  квартили как percentile_cont; при выборке < 30 окна нет;
- миграция 324: est_days_p25/p50/p75/n в trade_in_estimates, пишутся в
  INSERT и при ревайвле, поднимаются на GET (/estimate/{id} и /r/{token});
- API: exposure_window {p25_days, p50_days, p75_days, n}; старое поле
  est_days_on_market оставлено и равно p50 окна, у старых строк null;
- B2B hero: «До снятия объявления N–M дн.» вместо «Срок продажи»;
- /docs: убран «прогноз срока» из описания полного отчёта.

Порог 30 — замер 17.09 на 51 прод-когорте: ошибка края окна при 20 лотах
24%, при 30 — 18%. Окно есть у 94 из 217 недавних оценок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:44:44 +05:00
aefbd993bb fix(tradein/ui): «только этот дом» только когда выборка не расширялась соседями (#2583 M2)
Some checks failed
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 8m11s
CI Trade-In / changes (pull_request) Successful in 20s
CI / changes (pull_request) Successful in 33s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 3m17s
/house-analytics при <8 архивных лотах добирает до 30 соседних домов в
радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала
«История цен в этом доме · только этот дом», а рядом на том же экране стояло
«в радиусе 300 м».

Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и
соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый
PriceHistoryChart).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:59 +05:00
b9c89641d1 fix(tradein/gar): дом получает ГАР-guid только своего региона (#2583 H5)
Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону
улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это
стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50,
340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому
guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома.

Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри
региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого
региона (дом без региона не трогаем) — иначе дом без пары в своём регионе
так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:59 +05:00
a4e97c4b70 fix(tradein/estimate): город развёртки попадает в городской фильтр коридора сделок (#2583 H1)
Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам
(Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП
собирался по одной улице во всей области, где «Ленина» в основном
екатеринбургская, и Реж получал цену Екатеринбурга.

Резолвер теперь знает и города развёрток (кроме городов других регионов).
Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома.
Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках
ЕКБ ни одного адреса с этими именами отдельным словом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:59 +05:00
3b9742c60d fix(tradein): вечно недогружаемые карточки Яндекса больше не держат очередь добора (#3191)
Some checks failed
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 8m10s
CI Trade-In / changes (pull_request) Successful in 18s
CI / changes (pull_request) Successful in 34s
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
С 13.09 21:14 по 16.09 18:52 пять объявлений (10776456, 10775964, 10775945,
10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой
SPA-оболочкой ~381 КБ без блока контактов. Счётчика попыток на объявлении не было,
они возвращались в голову каждого снапшота и пятью недогрузами подряд выбивали
брейкер: 16 прогонов закончились ровно attempted=5 incomplete=5.

Миграция 321: listings.detail_incomplete_count / detail_incomplete_at.
yandex_detail_backfill пишет недогруз на объявление; снапшот не берёт карточку
сутки и не берёт совсем после трёх недогрузов (счётчик incomplete_given_up);
повторный недогруз уже известной карточки не двигает брейкер, первый — двигает,
защита от системного недогруза сохранена.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:25 +05:00
90b93b90b5 Merge origin/main в fix/proxy-pool-guards (#3299)
Some checks failed
CI Trade-In / 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 / changes (pull_request) Successful in 19s
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 / backend-tests (pull_request) Failing after 5m54s
Конфликт только в tests/skip_allowlist.txt: обе стороны дописали блок в конец —
live-тесты пула прокси (#3299/#3310/#3404) и миграции 310 (#3385). Оставлены оба.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:39:38 +05:00
fc5735fd6d Merge origin/main в fix/domclick-card-fields
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
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 5m46s
Конфликт только в tradein-mvp/backend/tests/skip_allowlist.txt: обе стороны
дописали блок в одно место (#3252 — тесты апсерта карточки ДомКлика, #3385 —
миграция 310). Оставлены оба блока. Номер миграции ветки 320 не пересекается
с main (максимум там 310).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:38:58 +05:00
a0522b9a86 fix(tradein/dedup): миграция 311 не встаёт в очередь за загрузкой ГАР — lock_timeout перед REINDEX (#1772)
CI / changes краснел гейтом #2752: «блокирующий DDL без lock_timeout (REINDEX INDEX
gar_house_flats_canon_idx)». Добавлен SET LOCAL lock_timeout = '5s' сразу под BEGIN.

REINDEX оценён замером на проде 17.09 (только чтение):
- gar_house_flats 3,27 млн строк / 942 МБ heap (1,5 ГБ с индексами), индекс частичный
  (flat_count > 0), 201 513 строк, 8,8 МБ;
- эквивалент перестройки (seq scan + tradein_canon_addr в один поток) 5,6 с — столько
  таблица закрыта для записи и для планировщика любого запроса к ней; читают её только
  ручной ГАР-загрузчик и ре-матч, пользовательский путь не задет;
- шаг S0b меняет ключ у 0 из 201 513 индексируемых строк: сегодня REINDEX ничего не
  меняет, но оставлен в той же транзакции, что и тело функции, чтобы строки, загруженные
  до деплоя, не остались со старым ключом. Прежнее обоснование («следующая загрузка ГАР»)
  было неверным: новые строки и так получают ключ нового тела.

REINDEX CONCURRENTLY возможен (раннер гонит файл psql'ем в autocommit, образец 270), но
не взят: ждёт все транзакции базы старше своего снимка (сборщики держат idle in
transaction минутами), lock_timeout не терпит, оборванный оставляет невалидный *_ccnew,
тело функции коммитится раньше индекса.

Тест по значению на живом Postgres: 311 гоняется как в раннере, пока «загрузчик» держит
ROW EXCLUSIVE на gar_house_flats — миграция падает LockNotAvailable быстрее 10 с, тело
функции откатывается (xmin строки pg_proc не меняется). Без SET LOCAL тест красный:
DID NOT RAISE — миграция дождалась загрузчика.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:28:55 +05:00
9f6701d3a5 Бэктест берёт тип дома из справочника домов, как боевая оценка (#2862)
Some checks failed
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 / changes (pull_request) Successful in 15s
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 5m14s
deals.house_type пуст у всех 434 911 сделок Росреестра (прод 17.09), поэтому
в прогоне с --resolve-house-id штраф за несовпадение типа дома в отборе
аналогов не срабатывал ни разу. Боевой estimate_quality с #3234 подставляет
тип из houses (по резолвленному house_id, иначе ближайший дом в 60 м), когда
его нет в форме; бэктест этого не повторял.

Теперь при --resolve-house-id пустой тип сделки дозаполняется тем же
_lookup_house_facts, свой тип сделки главнее. Счётчик house_type_from_houses
в блоке house_id_resolution показывает, скольким сделкам тип подставлен.
Год и этажность из houses намеренно не берутся: они сдвинули бы метрики по
другим признакам и смешали бы замер эффекта типа дома. Без флага вывод прежний.

Основная часть issue (код материала стен Росреестра -> тип дома) не сделана:
нужен справочник 126-УНСИ и решение владельца по соответствию.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:28:39 +05:00
a130303cc9 Merge pull request 'Сайдкар МЕРЫ: зона браузера по IP узла, якорь Домклика без Яндекса, автологин Циана через пул, время навигации в логах' (#3563) from fix/browser-sidecar into main
Some checks failed
Deploy Trade-In / build-browser (push) Successful in 2m54s
Deploy Trade-In / build-frontend (push) Successful in 3m49s
Deploy Trade-In / test (push) Failing after 5m1s
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 2s
Deploy Trade-In / changes (push) Successful in 15s
2026-09-17 09:24:11 +00:00
10c80e9e1d Merge pull request 'Скрапер: newbuilding-провайдеры без config больше не уходят мимо пула прокси, IMV Авито строит сессию общей фабрикой' (#3562) from fix/kit-providers-config 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-17 09:23:57 +00:00
1e99d91afe Merge pull request 'Сбор МЕРА: упавший прогон не висит zombie 6 часов, провалившаяся фаза не попадает в чекпоинт, проверка отмены не держит транзакцию часами' (#3561) from fix/kit-orchestration 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 / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (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-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:23:42 +00:00
e52057e66d Merge pull request 'Авито: добор карточек больше не сжигает 1600 попыток на упавшем сайдкаре и везде ходит браузером' (#3560) from fix/avito-detail-backfill 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-17 09:23:20 +00:00
a672178bcb Merge pull request 'Оценка: дедуп склеивает улицы вида «40-летия Комсомола», пороги Tier H — в настройки для бэктеста' (#3552) from fix/estimator-analog-quality 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-17 09:23:05 +00:00
a9e5322b7a Merge pull request 'Оценка: при якоре того же дома видно, что коридор сделок цену не ограничивал' (#3554) from fix/corridor-advisory-tier-a 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-17 09:22:46 +00:00
0be4d91d30 Merge pull request 'МЕРА: настройки оценщика, которые никогда не менялись, стали кодом — 4 кластера флагов #1970' (#3556) from fix/estimator-flag-collapse 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-17 09:22:26 +00:00
6a6fc7031d Merge pull request 'Алерты GlitchTip: медленный отказ Telegram отвечает 502 и уходит в фон, а не обрывается на 10-й секунде' (#3555) from fix/glitchtip-delivery 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-17 09:22:08 +00:00
6fe231c101 fix(ptica): «покрытие P% (Y из N комплексов)» — N одно и то же во всех ветках (#2464)
All checks were successful
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 2m55s
CI / backend-tests (pull_request) Successful in 7m16s
CI Trade-In / changes (pull_request) Successful in 28s
CI / changes (pull_request) Successful in 30s
best_layouts.py:1113 (эпик #2464). Поле objects_total_in_radius заполнялось
тремя ветками по-разному: ранний пустой ответ («фильтр никого не оставил»)
отдавал число комплексов ДО exclude/filter, ветка «нет velocity» и штатная —
ПОСЛЕ.

Смысл выбран по потребителям, а не по имени поля: BestLayoutsBlock.tsx и
layout_tz_pdf.py печатают его только как N в «покрытие P% (Y из N
комплексов)», а P в штатной ветке считается по отфильтрованным. Число до
фильтра сделало бы строку внутренне противоречивой («100% (1 из 2)»).
Поэтому ранний пустой ответ приведён к len(complex_groups); число obj_id до
фильтра по-прежнему отдаётся в raw_objects_total. Штатная ветка не менялась.

Тест проверяет поле во всех трёх ветках при filter_competitor_obj_ids;
два старых теста, закреплявших число до фильтра в пустом ответе, обновлены.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:21:51 +05:00
99f8525b1c Merge pull request 'Мера: о новой заявке узнаёт ответственный, а на дашборде видно, сколько оценок доходит до заявки' (#3551) from fix/lead-funnel 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
Deploy Metrics / server (push) Successful in 1m12s
Deploy Metrics / agent-apps (push) Successful in 36s
Deploy Metrics / agent-infra (push) Successful in 33s
2026-09-17 09:21:44 +00:00
57b03092ea Merge pull request 'Витрина лендинга: пустой пересчёт больше не считается успешным, а итог игры «Проверьте себя» говорит, что квартиры отобраны' (#3550) from fix/landing-showcase-and-game 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-17 09:21:02 +00:00
2b3602364a Merge pull request 'МЕРА: в истории цен Яндекса больше нет выдуманных «прошлых цен» в 10 и 1000 раз ниже' (#3564) from fix/yandex-price-decimal-slip 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 27s
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-17 09:17:27 +00:00
404b583682 Merge pull request 'МЕРА v2: клик по «Предыдущим оценкам» после отправки открывает выбранную оценку' (#3549) from fix/v2-history-click into main
Some checks failed
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
Deploy Trade-In / deploy-status (push) Has been cancelled
Deploy Trade-In / changes (push) Successful in 23s
2026-09-17 09:17:00 +00:00
af5eed19f9 Merge pull request 'Бэкапы: образцы настроек ведут алерты в тему «Metrics», неработавший сторож доступности удалён' (#3559) from fix/backup-notify-topic into main
All checks were successful
Deploy / build-backend (push) Successful in 3m27s
Deploy / build-worker (push) Successful in 5m9s
Deploy / build-frontend (push) Successful in 6m7s
Deploy Infra Host / sync-infra-host (push) Successful in 7s
Deploy / deploy (push) Successful in 1m42s
Deploy / deploy-status (push) Successful in 5s
Deploy / changes (push) Successful in 17s
Deploy / perimeter-smoke (push) Successful in 1m51s
Deploy / deploy-caddy (push) Has been skipped
2026-09-17 09:16:45 +00:00
d9a5bfe71f Merge pull request 'ПТИЦА: у изъятий на участке появляется номер постановления, а таблица не удваивается' (#3568) from fix/ptica-reservation-act-number into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 14s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
2026-09-17 09:16:07 +00:00
b2394b0a0f Merge pull request 'Каталог DOM.РФ в beat: вместо обещания «вернуть после cooldown» — факт о блокировке StormWall и тест, который не даст включить молча' (#3566) from fix/ptica-domrf-waf-probe into main
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / deploy-caddy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-09-17 09:15:47 +00:00
1812a1ed18 Merge pull request 'Метрики: правки prometheus.yml, конфига Loki, датасорсов Grafana и запросов postgres-экспортёров вступают в силу после деплоя' (#3557) from fix/metrics-deploy-reload into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy Metrics / server (push) Successful in 44s
Deploy Metrics / agent-infra (push) Successful in 28s
Deploy Metrics / agent-apps (push) Successful in 31s
2026-09-17 09:15:18 +00:00
d087e3076d Merge pull request 'Мера: тревога, когда бэкенд лёг, а лэндинг ещё открывается из кэша' (#3553) from fix/mera-observability 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-17 09:15:04 +00:00
d551cfbab9 test(tradein): объявить пропуски новых live-тестов защиты пула (#3299)
All checks were successful
CI Trade-In / 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 / changes (pull_request) Successful in 17s
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 5m32s
Четыре live-теста из правок по ревью без БД пропускаются, а гейт conftest
роняет прогон с DSN-заглушкой на необъявленных пропусках (rc=1 при
«6226 passed» в сводке). Внесены в skip_allowlist.txt к остальным тестам #3299.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:10:03 +05:00
452a0368ce Merge origin/main в fix/proxy-pool-guards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:02:38 +05:00
29ac3043b4 fix(tradein/proxy_pool): защита бана не считает узел с истёкшей арендой, условия резерва проверены по значению (#3299)
Ревью PR #3565: у нового предиката резерва две части не проверялись ни одним
тестом. Снятие `other.expires_at` из подзапроса acquire и снятие
`other.consecutive_fails` из подзапроса mark_banned оставляли прогон
test_3299 + services/test_proxy_pool.py зелёным (72 passed). Проверено тем же
способом, и нашлась третья такая часть: `other.expires_at` в mark_banned.

Добавлено по live-тесту на каждое условие: просроченный резерв в acquire,
резерв в карантине и просроченный резерв в mark_banned.

Попутно найден дефект рядом. Внешний отбор mark_banned («есть ли у источника
другой узел») срок аренды не проверял, хотя acquire такой узел не выдаёт, а
докстринг обещает «тем же правилом, что acquire()». Если у источника остались
только просроченные узлы, бан уходил последнему живому, и источник оставался
без прокси, пока healthcheck не наберёт просроченным узлам отказов. Добавлена
проверка `sp.expires_at` и тест на этот случай.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:02:32 +05:00
2639d2a7e7 Merge remote-tracking branch 'origin/main' into fix/kit-orchestration
All checks were successful
CI Trade-In / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 25s
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 6m11s
2026-09-17 13:59:43 +05:00
0327af6fab Merge remote-tracking branch 'origin/main' into fix/ptica-domrf-waf-probe
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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) Successful in 2m1s
CI / openapi-codegen-check (pull_request) Successful in 4m0s
CI / backend-tests (pull_request) Successful in 8m21s
2026-09-17 13:59:43 +05:00
69e7047398 Тесты гейта чекпоинта: частичный отказ фазы, порог в одну попытку, упавший запрос домов (#3415)
Ревью PR #3561: три мутации гейта оставались зелёными — порог `attempted >= 2`,
жадный гейт `failed >= 1` и снятый `houses_attempted += len(nb_id_list)` в ветке
«houses DB query failed». Добавлены проверки по чекпоинту:
- 1 из 2 домов отказал в каждом якоре — оба якоря в чекпоинте (контроль жадности);
- единственный дом якоря отказал — якорь не в чекпоинте (порог);
- упал запрос домов — якоря не в чекпоинте, houses_attempted == houses_failed == 2.

Код не менялся.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:58:43 +05:00
7cd15aab71 Отказ ручного сбора каталога DOM.РФ больше не советует ждать cooldown (#2443)
Ревью PR #3566: обещание таймера, убранное из beat, осталось в тексте
HTTP 400 эндпоинтов /admin/scrape/kn-catalog-objects и /kn-catalog-flats.
Отказ предлагал передать i_understand_waf_risk=true, «если WAF cooldown
прошёл», то есть подсказывал оператору обойти блокировку, которую ожидание
не снимает (зонды 20.08-01.09: StormWall, «Доступ заблокирован [403]»).

Теперь отказ называет StormWall и реальное условие: прокси и kn-прогон,
принятый по числу строк (#3307). Константа переименована в
_DOMRF_BLOCK_GUARD_MSG. Докстринги эндпоинтов и задач
scrape_kn_catalog_flats/objects больше не говорят про WAF cooldown и про
«вторник 04:00 UTC» у выключенной записи с расписанием в МСК.
api-types.ts перегенерирован как в CI-гейте openapi-codegen-check,
изменились только два докстринга.

Тесты отказа проверяют значение detail: #3307 есть, cooldown нет.
Поведение guard'а (400 без флага, задача не ставится) не менялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:58:31 +05:00
2e4a82dd46 Упавший прогон финализирует общий runs.mark_crashed: и ручные запуски из админки, и без дубля алерта (#1940)
Ревью PR #3561, две находки.

1. Ручные запуски из админки (avito/cian city sweep, cian_full_load,
   yandex_full_load, yandex_city_sweep) идут мимо scheduler._dispatch: в except
   был только logger.exception, и отказ «пул пуст» до try-финализатора пайплайна
   оставлял строку running до zombie. Логика финализации переехала из
   планировщика в runs.mark_crashed; её зовут планировщик и все пять ручек.

2. Обычный путь пайплайна — mark_failed и raise; планировщик звал mark_failed
   второй раз. UPDATE — no-op, но _alert_on_run_id срабатывал снова с тем же
   стриком, и на вехе лестницы в Sentry уходил дубль (+ WARNING «no-op»).
   mark_crashed сначала читает статус и финализирует только running.

Тесты по значению над двойником строки scrape_runs и боевыми mark_*:
статус banned/infra или failed после планировщика и после каждой из пяти ручек,
одна отправка в Sentry на неудаче пайплайна.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:57:34 +05:00
f48a510e94 Merge remote-tracking branch 'origin/main' into fix/estimator-flag-collapse
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 6m36s
2026-09-17 13:57:23 +05:00
91a075bc81 МЕРА: снимок фикстуры бэктеста снова знает пороги движка (#1970)
Ревью #3556: settings_at_capture в _write_fixture писал только поля estimate_*
из Settings. После переноса 31 порога в константы estimator.py будущая фикстура
не знала бы, с какими порогами её захватили. Теперь в снимок попадают и
числовые константы модуля (63 штуки, CORRIDOR_CLAMP_MIN_N в том числе).
replay_fixture снимок по-прежнему не читает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:57:07 +05:00
228c5f58f9 МЕРА: тест якоря «тот же дом» проверяет веса на месте вызова (#2381)
Ревью #3556: подмена rooms_boost=1.0 на месте вызова _compute_same_building_anchor
проходила весь сьют — тест значений держит только число в константе. Новый тест
оборачивает расчёт якоря и требует боевые sigma/rooms_boost/floor_sigma/min_comps/
mad_k (прод 17.09.2026). SB_GUARDRAIL_TOL, FSD_K, SB_GATE_MAX_FSD и сдвиг
ANCHOR_TIER_C_CORRIDOR_MULT вниз по-прежнему без поведенческих тестов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:57:05 +05:00
bc7fe9b0b9 МЕРА: тесты квартального индекса ловят пороги на месте использования (#2379)
Ревью #3556: тест значений держит число в константе, но не то, что код её читает.
Подмена min_n_deals=5 на обоих местах вызова проходила весь сьют. Хуже: Guard-2 и
bimodal-гард снимались целиком (`if False:`) при зелёном test_estimator_quarter_index —
оба теста были пустыми. Guard-2: заглушка отдавала аналогам индекс цели, factor=1.0.
Bimodal: аналоги без кадастра, поправку гасил Guard-5 (#2583) независимо от гарда.

- оба теста получили аналоги с известным уровнем 1.0 — без гарда цена сдвигается;
- границы: доля ровно 0.6 поправку даёт, индекс ровно 2.0 даёт, 2.01 — нет;
- заглушки lookup'ов пишут min_n_deals, тест требует 10 на обоих вызовах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:57:04 +05:00
2cfc2eda08 МЕРА: переименования порогов во фронте откачены — деплой без пересборки фронта (#1970)
Три правки в комментариях tradein-mvp/frontend (имена estimate_corridor_clamp_min_n
и estimate_imv_blend_weight → константы) кода не меняли, но фильтр deploy-tradein
по tradein-mvp/frontend/** пересобирал и пересоздавал tradein-frontend ради
комментариев. Комментарии переименуются вместе с ближайшей правкой фронта
(в #3554 фронт и так пересобирается — переименование приложено к нему патчем).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:56:48 +05:00
c7a03495e2 Свип ДомКлика: корзина без сохранённых строк не попадает в чекпоинт (#2406)
Ревью PR #3561: тест таймаута фазы закреплял как контракт done_buckets == ['st'].
Лоты ДомКлика копятся в памяти и пишутся одним save_listings после всех корзин,
поэтому снятая watchdog'ом (или упавшая на save) фаза не сохраняет ничего, а
чекпоинт всё равно получал completed_buckets живого скрейпера — следующий прогон
пропускал корзину навсегда (механизм миграции 308).

Чекпоинт пополняется только если фаза дошла до конца (флаг _saved после save).
Тест развёрнут: при таймауте fetch_city и при падении save_listings
done_buckets == [], статус failed. Контроль «полный проход несёт
унаследованное ∪ пройденное» — test_3369.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:54:55 +05:00
3992d7546f Merge origin/main в fix/estimator-analog-quality
All checks were successful
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
CI Trade-In / changes (pull_request) Successful in 15s
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:51:04 +05:00
cbf0f6dec6 docs(mera/estimator): ручки Tier H временные — вернуть в константы после бэктеста (#3234)
Ревью #3552: направление расходится с #3556/#1970, где неменявшиеся
estimate_*-настройки переводят в константы движка. Две новые ручки нужны
только для бэктеста вариантов; условие возврата записано рядом с полями,
чтобы его увидел тот, кто будет делать шаг 4 #3234.

Refs #3234

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:50:49 +05:00
45017d2535 fix(mera/estimator): дедуп различает порядковые улицы «2-я Синичкина» во всех трёх форматах (#2291, класс 4)
Что было. После «40-летия Комсомола» остались однобуквенные порядковые:
регексы требовали после «N-» минимум две буквы. Одна улица в трёх форматах
разбиралась по-разному: cian «улица 2-я Синичкина» давал ('', ''),
avito/yandex «2-я ул. Синичкина» и domklik «2-я Синичкина улица» теряли номер
('синичкина'). Потеря номера — это ложная склейка: «9-я Парковая, 5» и
«12-я Парковая, 5» получали один ключ ('парковая', '5').

Что сделано. В обоих регексах названия улицы добавлен необязательный
порядковый «N-x » перед (нумерованным) именем; формат «номер тип имя»
переставляется в «тип номер имя» до разбора. Номер дома с дефисом не тронут.

Замер на проде (tradein-postgres, SELECT, 17.09): 12 848 разных адресов с
шаблоном «цифра-дефис/пробел-буква», 29 959 объявлений. Разбор изменился у
8 713 объявлений; ни один адрес не потерял токен. Межисточниковых групп
(улица, дом) 1305 -> 1473. Из 81 прежней группы, которая распалась, 60 —
разные порядковые улицы, 3 — ложный mkr-ключ, 18 — голая и порядковая
улица в разных городах (одна — источник сам потерял номер). Гейт бэктеста
без изменений baseline.

Refs #2291

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:50:31 +05:00
8bcb025c0d Merge origin/main в fix/corridor-advisory-tier-a
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m36s
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 13s
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 1m38s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:46:11 +05:00
1a9a612a82 test(mera/estimator): тесты дедупа #2291 не трогают флаг, который #3556 удаляет
Два новых теста класса 4 ставили monkeypatch.setattr(settings,
"estimate_dedup_analogs_enabled", True). PR #3556 (#2378) удаляет это поле из
Settings, и setattr по несуществующему атрибуту падает AttributeError. CI
гоняет голову ветки, а не merge-ref, поэтому второй смерженный PR пришёл бы
зелёным и уронил main. На main флаг и так ON по умолчанию
(test_dedup_default_is_on), так что строки лишние при любом порядке мержа.

Проверено: дифф PR, наложенный на голову #3556 (31c31f8f), до правки
2 failed / 144 passed (AttributeError), после 146 passed. Тесты не стали
пустыми: откат префиксного регекса без флага даёт assert 4 == 1 и 1 == 2.

Refs #2291

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:44:45 +05:00
803ea2217b test(tradein-front): подпись коридора в v1 HeroSummary стережётся рендером (#3466)
Ревью #3554: вызов corridorAdvisoryNote в HeroSummary (корневая /trade-in)
тестом не покрывался — мутация F2 убирала аргумент estimate.analog_tier,
vitest оставался зелёным (305 passed). Покрыт был только v2 mappers.

Рендер HeroSummary по образцу thinMarketBadge.test.tsx: n=20,
advisory_only=false; same_building → подпись про дом в тексте hero,
micro_radius → подписи нет. Мутант F2 краснеет: «expected 'Секция 1 ·
Сводка…' to contain 'справочно: цена посчитана по аналогам…'».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:44:36 +05:00
49bcfdacd6 test(tradein): граница radius-floor при n == порогу стережётся (#3466)
Ревью #3554: замена `>=` на `>` в гейте radius-floor оставляла
test_estimator_radius_floor.py зелёным — в тестах были только count=15 и
count=min_n−1. При n == min_n витрина не пишет «справочно»
(advisory_only=False), значит floor обязан поднять цену.

Новый тест: n = estimate_corridor_clamp_min_n, медиана 80k → ровно 120k,
плюс проверка, что DkpCorridor на той же границе не справочный. Мутант
`>` краснеет: «коридор из 10 сделок (ровно порог) должен поднять цену
ровно до floor=120000».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:42:57 +05:00
6e8c261ff8 fix(tradein/domclick): собственники, нулевые площади и жилая площадь карточки перестали теряться (#3252)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
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 4m55s
Сверка парсера с живым __SSR_STATE__ трёх карточек (17.09) нашла три тихие дыры:

1. owners_count. egrnData отдаёт поля ЕГРН обёрткой {status, value}; int() от
   словаря молча давал None. Прод: 0 из ~2970 карточек, обогащённых с 29.08,
   против 5075 из 6296 у ручного прогона 18.07, который обёртку разворачивал.
   Ключ egrnData.area подтверждён — гадания rosreestrArea/object_area удалены,
   в raw_payload обёртка остаётся как есть (status = сверка с объявлением).
2. livingArea/kitchenArea = 0 у Домклика значит «не указано», а в колонку ложился
   0.00 и затирал известное значение. Прод: 303 кухни и 84 жилые площади = 0,
   только domklik. Разбор: _pos_float; строки: миграция 320 ставит NULL.
3. Жилую площадь и балконы стирал переобход выдачи: апсерт scraper_kit.base писал
   их сырым EXCLUDED, а SERP Домклика и Авито этих полей не отдаёт. Прод: domklik,
   переобойдённые после обогащения, living_area_m2 0 из 5217 (без переобхода
   2990 из 4058); avito 1 из 8639 (4122 из 6505). COALESCE в SET, в гейте #2992
   и на reconcile-пути.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:25:38 +05:00
7501fef5c3 fix(ptica): номер акта в land_reservation извлекается у постановлений Администрации ЕКБ (#2982)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 14s
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 2m31s
CI / backend-tests (pull_request) Successful in 6m44s
Регекс номера требовал суффикс областных актов (-ПП/-ПА/-РП/-ПГ/-ГП/-МО),
а у постановлений Администрации Екатеринбурга его нет: на проде act_number
пуст у всех 27 строк (17.09.2026).

- izyatie_ocr: номер берётся у того же акта, чью дату выбирает
  _extract_act_date (вплотную после «от DD.MM.YYYY», иначе перед ней).
  Первое «№» в теле — «Решение Думы № 60/1» или «Приказ № 746-П».
- page_reservation_parser: суффикс необязателен; номер с суффиксом не из
  списка («218-ФЗ», «746-П», «60/1») отбрасывается целиком, а не обрезается.
- izyatie_ocr_ingest: перед записью удаляется прежний разбор того же
  участка из того же документа с другим номером. act_number в ключе
  конфликта, без этого прогон положил бы 27 строк с номером рядом с 27
  строками без номера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:24:06 +05:00
9cc23c6698 Выключить домклик-свипы 77/50 до инкрементального сохранения (#3567)
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 4m5s
Deploy Trade-In / build-backend (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 7m52s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s
2026-09-17 08:23:05 +00:00
47a91acf61 test(tradein): объявить пропуски live-тестов пула прокси (#3299, #3310, #3404)
All checks were successful
CI Trade-In / 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 / changes (pull_request) Successful in 15s
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 5m47s
Без БД 11 новых live-тестов пропускаются, и гейт conftest роняет прогон
(«НЕУЧТЁННЫЙ ПРОПУСК», rc=1 при «6226 passed» в сводке). В ci-tradein.yml они
идут по-настоящему: локальный прогон на Postgres со схемой из миграций —
6278 passed, 1 skipped, rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:14:01 +05:00
b2ff92afbb beat: каталог DOM.РФ выключен из-за блокировки StormWall, а не «до cooldown» (#2443)
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 17s
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 2m45s
CI / backend-tests (pull_request) Successful in 6m59s
Комментарии у закомментированных записей scrape-kn-catalog-objects-weekly и
scrape-kn-catalog-flats-weekly обещали «возврат после cooldown 24-48h
(проверить через targeted test)». Тест проведён 20.08, 27.08 и 01.09:
кулдауна нет (kn-прогоны 29-33 шли в июне, уже после «бана» 24.05), с 01.09
наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
Комментарии теперь называют факт и условие включения (прокси и принятый
kn-прогон, #3307).

Решение больше не живёт только в комментарии: тест проверяет по значению
(по task, не по ключу), что ни одна из двух задач не попала в собранное
beat-расписание, и в тексте падения ведёт в #3307.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:09:44 +05:00
4961128f87 fix(scraper-kit/cian): детальная Циана на своей сессии — пул прокси не на event loop (#3408 п.3)
fetch_detail без session/browser_fetcher входил в синхронный curl_proxy_url:
acquire/mark_health/release RealProxyProvider'а ходят в БД прямо на loop'е.
Вызывающий — админ-ручка истории цен Циана в публичном tradein-backend
(один воркер uvicorn): пара блокирующих вызовов на каждый листинг батча.
#3398 перевёл так три сайта /estimate, этот остался.

Теперь async with acurl_proxy_url: вход и выход в потоке, contextvars
(current_run_id) копируются, ProxyBanError/CianBlockedError изнутри блока
доходят до пула как раньше (test_2700/test_3402 зелёные). Попутно потолок в
докстринге acurl_proxy_url: pool_timeout теперь 5 с (#3444), не 30.

Тест по образцу test_3398: acquire спит 0.3 с, соседняя корутина тикает.
На синхронном входе — «тиков всего 0».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:04:54 +05:00
e83742ac41 МЕРА: yandex-история больше не сеет цену на порядок ниже/выше текущей (#3385)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 21s
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 5m40s
Все 7 скачков ×10 в yandex-истории цен на проде (17.09) — не «сдвиг на следующем
наблюдении», как предполагал issue, а синтетический посев из gate-поля
price.previous: нижняя точка стоит ровно на 24 ч раньше второй и записана тем же
батчем. Ошибочна нижняя цена (13–37 тыс/м² против 133–375 у верхней). Ещё 4 посева
того же рода — ×175…×1000 (цена в тысячах). Ветка писателя «история есть» на проде
не записала ни одной строки: смену цены раньше неё пишет триггер
record_listing_price_change, поэтому отлов «на следующем наблюдении» там поймал бы 0.

- yandex_price_history: price.previous, отличающаяся от текущей цены в 9.5 раза и
  больше в любую сторону (или не > 0), не сеется; счётчик seed_rejected в логе.
- миграция 310: удаляет yandex-посевы по подписи батча (recorded_at совпадает,
  change_time второй точки ровно +24 ч) с тем же порогом; lock_timeout 5s,
  остановка при > 30 строк; на проде ровно 11 кандидатов, повторный прогон — 0.
- тесты по значению: посев отвергнут/сохранён на прод-ценах; настоящий файл
  миграции на живом Postgres через временную таблицу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:04:02 +05:00
ab003877dc fix(tradein/proxy_egress): прогон на egress-пути тоже знает свой узел (#3404)
PR #3405 писал scrape_runs.proxy_id только из proxy_pool.acquire(). Прогоны,
которые берут прокси через proxy_egress.resolve_proxy_url без аренды
(yandex_detail_backfill, yandex_address_backfill, curl-ветка
avito_detail_backfill), атрибуцию не получали: прод 17.09 —
yandex_detail_backfill 0 из 56, yandex_address_backfill 0 из 1.

resolve_proxy_url при выбранном узле и выставленном current_run_id зовёт
attribute_run_proxy. Своей короткой сессией: db вызывающего — долгоживущая
сессия прогона посреди работы, а атрибуция коммитит и на сбое откатывает.

Тесты на живом Postgres: атрибуция внутри прогона (proxy_id и
counters.proxy_ids), вне прогона scrape_runs не тронут, незакоммиченная
работа вызывающего не коммитится. Красные прогоны: на старом коде
«assert None == 70»; с атрибуцией через db вызывающего — «атрибуция
закоммитила чужую транзакцию».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:03:22 +05:00
611878df4c fix(scraper-kit/proxy): бан на curl-пути не копит счётчик отказов узла (#3310)
Защита последнего узла в mark_banned бережёт узел от бан-строки и пишет
«узел продолжит выдаваться», но curl_proxy_url на том же ProxyBanError
вслед за баном звал mark_health(ok=False). Три ответа 403/капчи подряд —
consecutive_fails = 3, и acquire отсекал узел для ВСЕХ источников: пул
пуст при живом узле. Браузерный путь это исправил в #3288
(report_platform_ban), curl-путь — нет; через него ходят cian detail,
ЖК-резолв, история цен Циана и оценщик.

Теперь на ProxyBanError зовётся mark_banned вместо mark_health(False).
Транспортные сбои по-прежнему засчитываются узлу.

Тест на живом Postgres настоящим трактом (curl_proxy_url →
RealProxyProvider → proxy_pool): единственный узел, три CianBlockedError →
consecutive_fails == 0, acquire('cian') выдаёт узел; на старом коде
«assert 3 == 0». Пять старых проверок ждали mark_health(False) на бане —
они фиксировали дефект, поправлены.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:01:24 +05:00
1a773554db Сайдкар: в логах /fetch появилось время целевой навигации — замер хвоста Циана (#3419)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 18s
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 / browser-tests (pull_request) Successful in 1m47s
CI Trade-In / backend-tests (pull_request) Successful in 5m54s
Порог BROWSER_NAV_TIMEOUT_MS=60000 не с чем было сравнить: «fetch OK» писался
на DEBUG и без длительности, «fetch error» — тоже без неё, а время из
access-лога включает пейсинг (18 с у cian) и ожидание лока. За сутки до 17.09
у cian 54 TimeoutError на ~1350 страниц (90 recycle × 15), ~4 %.

Теперь _fetch_once меряет только целевую page.goto (monotonic, в finally —
и на таймауте), обе строки несут nav_ms, «fetch OK» переведён на INFO.
nav_ms=None — до цели не дошли (упал прогрев origin), прошлое число не
наследуется. Решение по порогу — после 48 ч данных, это отдельный шаг.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:00:23 +05:00
92295971fe fix(tradein/proxy_pool): резерв выделенного узла считает узлы 'any' (#3299)
Запасной заход acquire() и защита последнего узла в mark_banned() спрашивали,
есть ли у выделенной привязки ВТОРОЙ узел той же привязки. Выделенный узел
штучный, поэтому ответ почти всегда «нет», и такой узел не выдавался никому,
хотя свой источник обслуживали 'any'-узлы. Прод 17.09: узел 15 (avito)
свободен и здоров, а cian/domclick его не получали; 30.08 так лёг добор
Домклика (прогоны 5449-5459).

Теперь резерв — любой узел той же привязки или 'any', здоровый
(consecutive_fails), с живой арендой порта и не забаненный источником
выделенной привязки. Предикат одинаковый в обоих местах. leased_by
намеренно не проверяется: аренда временная.

Тесты на живом Postgres (tests/test_3299_*): 6 случаев из приёмки, два
красные на старом SQL. Мок test_proxy_pool.py: маршрутизация primary/fallback
по полному фрагменту IN (:provider, 'any') и гейт нового предиката.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:59:44 +05:00
4549311720 Автологин в Циан идёт через узел пула, а не через мёртвый env-узел сайдкара (#3410)
/login сайдкара не принимал proxy из тела (_no_live_proxy(provider, None),
_ensure_browser без override), BrowserFetcher.login его не клал, а ручка
cian auto-login сознательно не брала провайдер. Итог: логин всегда шёл с
SCRAPER_PROXY_URL сайдкара — на проде это выключенный узел 9 (InvalidIP),
то есть ручка восстановления cian-сессии не работала вовсе.

- сайдкар: /login берёт proxy из тела, как /fetch (guard и _ensure_browser
  с override; crash-relaunch в _do_login сохраняет _launched_proxy);
- scraper-kit: login() кладёт в тело узел текущей аренды, как _post_fetch;
- backend: ручка передаёт _kit_proxy_provider(), как debug-карточка DomClick;
  пустой пул на проде — 503 «нет свободного узла для cian», а не 502.

Тесты #3197 про «логин без пула» переписаны на обратные утверждения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:58:03 +05:00
808a4fb4cb refactor(tradein): IMV Авито строит curl-сессию общей фабрикой, а не своей копией (#2386)
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m55s
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
avito/imv.py сам собирал curl_cffi AsyncSession с третьей копией
document-заголовков (_DOC_HEADERS) — из-за того, что parity-тесты патчили
curl_cffi.requests.AsyncSession, а _base импортирует класс на уровне модуля и
такой патч до него не долетает. Взят вариант B из issue: в кодовой базе уже
принят патч scraper_kit.providers._base.AsyncSession (test_scraper_proxy.py,
test_pipeline_browser_routing.py, test_kit_serp_proxy_pool.py), а вариант A
(живой lookup в _base) сломал бы эти тесты — атрибута _base.AsyncSession не стало бы.

Сессия теперь build_document_session(proxy_url, timeout=25); _DOC_HEADERS
удалён (идентичен DOCUMENT_HEADERS, проверено до правки), мёртвый
try/except ImportError убран — curl_cffi и так импортируется через _base.
Тесты патчат _base.AsyncSession; ассерт с config дополнительно фиксирует
impersonate, timeout=25 и заголовки — параметры сессии не изменились.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:57:29 +05:00
d2a31dff08 Тесты kit-свипов: таймаут якоря, дрейн и отмена посреди обхода у всех свипов, а не только у Авито (#2406)
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
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 15s
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
После удаления legacy-пайплайна эти сценарии были заассерчены только у avito
(test_3319) и дрейн до первого якоря у yandex/cian (test_3333). Добавлено:
- таймаут якоря у cian и yandex: якорь не в чекпоинте, следующий обработан,
  errors_count растёт; таймаут фазы у domclick: завершённые корзины в чекпоинте,
  статус failed, а не done;
- SIGTERM-дрейн после первого якоря у cian и yandex: interrupted=1, в
  чекпоинте ровно первый якорь, второй не посещён;
- пользовательская отмена (is_cancelled=True) после первого якоря у avito,
  cian и yandex: второй якорь не посещён, чекпоинт на месте, без mark_done.

Проверка по строке прогона: фейковая БД мержит counters всех записей, как
jsonb-мерж в runs.py. Мутации (снятый continue/return/interrupted/errors_count
в pipeline.py) роняют ровно свой тест, 6 из 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:57:13 +05:00
eade88bc66 fix(tradein): config обязателен в newbuilding-провайдерах Циана и Яндекса (#2385)
fetch_newbuilding, resolve_cian_zhk_url_via_search, YandexNewbuildingScraper и
resolve_yandex_jk_slug принимали config=None и в этом случае строили
BrowserFetcher(endpoint=None) мимо фабрики, а резолвер Циана — сессию без прокси.
Все боевые вызовы config уже передают, но новый вызывающий без config молча ушёл бы
на env-узел сайдкара мимо пула — именно так потеряли 8 суток в #2767 и сломались
в #3197. Теперь config обязателен по сигнатуре, запасные ветки удалены, фетчер
строит только build_browser_fetcher.

Тесты: кейсы *_without_config (ждали endpoint=None) заменены одним — без config
TypeError до сети, фетчер и сессия не строятся; остальные тестовые вызовы
получили config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:55:32 +05:00
6be2bf9aaa fix(tradein/avito): серия отказов нашего тракта обрывает detail-добор статусом failed (#3439)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
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 5m12s
Прод: 7193, 7218, 7264, 7302 (15-16.09) и 6608/6631/6643/6662 (10.09) — по
1600 попыток, 1-10 карточек, ~1599 блоков, status='done', по 70+ минут
каждый. В те же окна в логах tradein-browser шторм «browser launch failed …
InvalidIP» и «/fetch 503». 6885 (13.09) — те же 1600 попыток при
ban_kinds {"infra": 1591}, но 'banned'.

Корень — регресс #3288/#3367: record_block(kind != platform) уходит в
record_failure(), то есть только в знаменатель окна. Числитель доли растёт
лишь на platform, safety-net смотрит на platform-серию, счётчика серии
infra нет вовсе. Серия отказов сайдкара любой длины не рвала прогон ничем,
а финализатор #3367 при одной карточке ставил 'done'.

- BlockRatioBreaker: infra_streak_limit и счётчик серии infra. Серию гасит
  любой ответ площадки — успех, её блок, 404. abort_reason() -> "infra_streak"
  (проверяется первым), abort_explanation() называет эту величину.
- avito_detail_backfill: порог = max_consecutive_failures (дефолт 25) — тот же
  разряд «отказ нашей стороны», что и отказы-не-блоки. На обрыве — mark_failed
  «тракт недоступен: N отказов … подряд без единого ответа площадки», в
  counters abort_reason=infra_streak и ban_kinds. Не 'banned' — площадка
  молчала; не через mark_backfill_finished — там одна карточка даёт 'done'.

Тесты по значению: 1 успех + 1599 отказов сайдкара -> attempted=26,
mark_failed, abort_reason=infra_streak, ban_kinds {"infra": 25}; обрыв ровно
на 25-м (24 — нет); 24 infra + ответ площадки (успех/блок/404) + 24 — не
обрыв; 24 infra + ответ, трижды — снапшот пройден; 25% настоящих блоков —
не обрыв; прежний контроль #3367 (20 infra + 10 успехов -> done) зелёный.

Фальсификация: без проводки порога — «assert (1600, 1600) == (26, 26)»; без
сброса серии на блоке площадки — «abort_reason='infra_streak': 24 + ответ
площадки (platform) + 24»; без ветки финала — «прогон ушёл в
backfill-honest-status: там 1 карточка превращает шторм в 'done'».

Пункт 3 задачи (browser_unfit узлу, на котором сайдкар не поднял браузер)
не сделан: это вердикт пула прокси и протокол сайдкара, отдельная задача.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:54:18 +05:00
fb02e41d77 Проверка отмены закрывает свою транзакцию: свип ДомКлика больше не держит горизонт vacuum на весь обход (#3480)
Виновник по живому снимку pg_stat_activity 17.09 07:48 UTC: pid 1933154 из
tradein-scraper, 'idle in transaction' 1 ч 35 мин, последний запрос
`SELECT status FROM scrape_runs WHERE id = $1` (runs.is_cancelled),
xact_start через 30 мс после старта domclick_city_sweep_moskva 7344.
По Prometheus за 03-17.09 10 из 12 окон «транзакция > 1 ч» совпадают по
началу и концу со свипами ДомКлика.

is_cancelled делал SELECT без commit, а вызывающие сразу уходят в сетевую
фазу (у ДомКлика — весь fetch_city, до 3 ч). Теперь commit после чтения —
в общей точке для всех 12 вызовов; незакоммиченных записей, рассчитанных на
откат, перед вызовами нет (перед каждым — update_heartbeat/save_listings с
commit или чтения).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:54:04 +05:00
6c5f327acd Сайдкар: якорь Домклика открывает свою выдачу напрямую, без захода через поиск Яндекса (#3263)
Выдача Яндекса капчит конкретные выходные узлы пула (замер владельца 29.08:
узлы 1/10/11 — капча, 9 и домашний IP — выдача), поэтому путь через поиск
срабатывал ~2 раза из 9 и стоил ~15 с на подъём якоря. В логах tradein-browser
15-16.09 все 7 попыток — капча, и все 7 через 0,2 с заканчивались «якорная
вкладка не поднялась (Error)». Referer карточке и так даёт переход с выдачи
площадки (detail.py: referer=origin).

Дефолт BROWSER_ANCHOR_VIA_SEARCH теперь пустой: путь через поиск остаётся,
но только по явному env. Тесты яндекс-пути включают его явно; новый тест
грузит модуль без env и проверяет, что якорь идёт прямым goto(origin).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:53:04 +05:00
a6c86e88ea fix(tradein/avito): диагноз блоков ложится в counters любого финала добора, не только 'banned' (#3288)
Пункт 5 из комментария 05.09: ban_kind пишется только у banned-прогонов, у
done/failed диагноза в БД нет. Прод: 7193, 7218, 7264, 7302 — по 1600
попыток, ~1599 блоков, status='done', counters без ban_kinds; что это были
отказы сайдкара, а не площадки, видно лишь по косвенному признаку (мелкая
block_streak_histogram считает только platform-серии). mark_backfill_finished
кладёт перепись в counters только на ветке бана.

Задача теперь кладёт counters.ban_kinds сама перед любым финализатором
(mark_backfill_finished / mark_failed пустого пула). Статусы и колонка
ban_kind не тронуты — контракты #3196/#2764 как были.

Тест по значению: 20 отказов сайдкара + 10 успехов — counters, уходящие в
финализатор, несут {"infra": 20}. Фальсификация: без строки —
«assert None == {'infra': 20}».

Стоп-кран для infra-серии (вторая половина п.5) — отдельным коммитом #3439.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:51:44 +05:00
19b6081242 fix(tradein): режим detail-добора Авито одинаков во всех контейнерах образа backend; убрана мёртвая YANDEX_COOKIES_FILE (#3182)
Прод 17.09, printenv: AVITO_DETAIL_BACKFILL_USE_CURL=false у tradein-scraper
и true у tradein-backend/tgbot/browser. Источник true — хостовый
backend/.env.runtime:37; false задан только environment'ом сервиса scraper.
tradein-backend исполняет добор сам (ручка админки
scrape_avito_detail_backfill), то есть ручной прогон шёл curl'ом, который
QRATOR PoW не проходит.

- docker-compose.prod.yml: AVITO_DETAIL_BACKFILL_USE_CURL "false" у backend
  и tgbot — у всех сервисов образа backend одно значение (environment
  перекрывает env_file). tgbot добор не исполняет; строка там, чтобы правило
  «одно значение на образ» проверялось без списка исключений.
- Остальные флаги режима проверены на проде: SCRAPER_FETCH_MODE=browser и
  AVITO_IMV_USE_BROWSER_FETCHER=true одинаковы во всех четырёх контейнерах.
- yandex_cookies_file удалён из Settings: его не читал ни один модуль
  (только мок в test_scraper_proxy.py), файла /app/yandex_cookies.json нет
  ни в одном контейнере, куки Яндекса давно живут в БД (#3192).
  extra="ignore" держит старт при оставшейся переменной в .env.runtime.

Тест по значению: разобранный compose — у каждого сервиса образа backend
значение "false"; Settings с YANDEX_COOKIES_FILE в env стартует без поля.
Фальсификация: без строки у backend — «{'backend': None} != {'backend':
'false'}»; с возвращённым полем — «assert not True».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:50:20 +05:00
c871f804cf Сайдкар: таймзона браузера берётся из IP узла, а не жёстко МСК (#3187)
camoufox 0.5.5 при geoip=True кладёт координаты из GeoIP-записи exit-IP
безусловно, а timezone — через setdefault, поэтому ручной
config["timezone"]="Europe/Moscow" перебивал зону из IP. Узлы пула 13 и 15
(T2 NSK, UTC+7) и 14 (МегаФон Самара, UTC+4, RIPE 17.09) заявляли МСК при
сибирских/самарских координатах, а библиотека писала LeakWarning на каждом
запуске (4715 штук за 13 ч при 665 запусках). Ротация узла идёт внутри
подсети оператора, смена узла релончит браузер — зона с запуска не протухает.

Тест: в kwargs запуска нет ключей timezone/geolocation:* в config, geoip=True.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:49:37 +05:00
7218c2094c Бэкапы: образцы env ведут алерты в тему «Metrics», мёртвый uptime-сторож удалён (#3164)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 24s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 29s
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 3m15s
CI / backend-tests (pull_request) Successful in 7m37s
Тема форума для уведомлений бэкапов задаётся только env-файлом на хосте, а в
образцах её не было вовсе. На проде она задана, но не та: 158 («алерты») в
/opt/gendesign/secrets/backup-notify.env и forgejo-backup.env на Beget и в
/etc/default/gendesign-backup на Poincare. По решению #3163 инфраструктура идёт
в 245 «Metrics». Значение на хостах этот коммит не меняет.

- ops/gendesign-backup*.default.example: строка #TELEGRAM_TOPIC_ID=245 с
  причиной и ловушкой: тема обязана лежать в одном файле с токеном и чатом,
  иначе notify() её не прочитает.
- ops/crontab-beget.cron сверен с живым crontab Beget: сторожа и бэкап волта
  получают BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env. Без него
  переустановка crontab из репозитория глушила бы алерты бэкапов на Beget.
- ops/uptime-healthcheck.sh и его образец удалены: скрипт не запущен ни на
  одном хосте (crontab, cron.d, таймеры), доступность сторожат uptime-мониторы
  GlitchTip на Beget (gendsgn.ru, /health, meraocenka.ru — раз в 60 с).

Тест исполняет настоящий check-backup-staleness.sh с образцом, заполненным
по инструкции, и проверяет адрес в вызове curl: message_thread_id=245.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:49:33 +05:00
facdbb2566 Свипы Циана и Авито: якорь с полностью отказавшей фазой не попадает в чекпоинт (#3415)
cian_city_sweep 6179 (06.09) — failed по «фаза houses отказала полностью, 30 из
30», но в done_buckets все пять якорей. 6276 резюмировался от него, пропустил
все якоря и отчитался done с houses_attempted=0, lots_fetched=0.

Отметка «якорь пройден» опиралась только на поток управления, а фаза отказывает
и без исключения (fetch_newbuilding -> None, растёт счётчик). Гейт
_bucket_phase_totally_failed сверяет прирост пар X_attempted/X_failed за якорь;
порог одна попытка. Тот же гейт в run_avito_city_sweep (detail). Ветка «houses
DB query failed» теперь растит houses_attempted вместе с houses_failed.
Форма чекпоинта прежняя (плоский список имён).

Перенос ветки fix/3415-bucket-done-only-if-phases-ok (19a335f4) на свежий main,
применилась без конфликтов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:48:20 +05:00
e42e370ac9 fix(tradein/avito): detail-добор по умолчанию идёт браузером, а не curl (#3045)
curl_cffi не проходит QRATOR proof-of-work Авито в принципе (замер 21.08:
~6% обогащений против ~74% на браузере). Шаг 3 задачи был сделан только
environment'ом сервиса scraper, дефолт в config.py остался True. Добор при
этом исполняет и tradein-backend — ручка админки scrape_avito_detail_backfill
вызывает run_avito_detail_backfill в своём процессе, и там printenv даёт true.
Ручной прогон через админку шёл curl'ом.

Дефолт avito_detail_backfill_use_curl -> False. Комментарий в задаче «true
только у tradein-backend, который добор не запускает» был неверен — исправлен,
как и два упоминания use_curl=True «прод-дефолтом».

Тест по значению: Settings без env и без .env даёт False; контроль — env
по-прежнему переключает на curl. Фальсификация: default=True ->
«AssertionError: assert True is False».

На проде хостовый backend/.env.runtime:37 задаёт true явно, поэтому для
tradein-backend нужен ещё environment-оверрайд — он в коммите #3182.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:48:12 +05:00
8e5f097c5e Планировщик: упавший до финализатора прогон получает статус сразу, а не zombie через 6 часов (#1940)
Исключение, вылетевшее из хендлера до его try с mark_failed (прод: пустой пул
прокси в BrowserFetcher.__aenter__ через 10 мс после claim), планировщик только
логировал. Строка оставалась 'running' с heartbeat_at == started_at, reaper через
6 ч ставил 'zombie' — за 21 сутки так 18 avito-свипов (7349 и 7350 висят сейчас).

_dispatch._run теперь финализирует прогон сам: пустой пул прокси в цепочке
причин -> banned/ban_kind=infra (как в пайплайнах), остальное -> failed.
Уже финализированную хендлером строку не трогает (WHERE status='running'),
пустые counters мержатся и чекпоинт не стирают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:47:05 +05:00
ec85ea1861 fix(metrics): правка queries.yml доезжает до postgres-экспортёров (#3486)
All checks were successful
CI Trade-In / 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 / changes (pull_request) Successful in 19s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 3m19s
CI / backend-tests (pull_request) Successful in 8m6s
queries.yml смонтирован трём экспортёрам одним файлом и читается только при
старте, а агентские джобы деплоя пересоздавали лишь alloy: правка ложилась на
диск новым инодом, экспортёры продолжали отдавать старые запросы при зелёном
деплое. Разрыв латентный — 17.09 иноды хоста и контейнеров совпадают
(Poincare 5112170, Beget 569352).

agent-apps и agent-infra после подъёма сверяют инод queries.yml у своих
экспортёров через ops/metrics/recreate-stale-mount.sh и пересоздают только при
расхождении, под гейтом профиля. Гейт пофайловых маунтов теперь читает и
docker-compose.metrics-agent.yml (сервис → джоба по профилю), а ci.yml
запускает backend-тесты на правку этого файла.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:43:48 +05:00
e5db8dd80f fix(metrics): правки prometheus.yml, loki-config.yml и датасорсов Grafana доезжают до работающих сервисов (#3467)
Хвосты #3467, не попавшие в main вместе с #3476:

- prometheus.yml смонтирован одним файлом: после git reset --hard reload
  перечитывал СТАРЫЙ инод с rc=0 и новым lastConfigTime (стенд
  prom/prometheus:v3.1.0, 17.09). Комментарий в деплое утверждал обратное.
  Теперь promtool проверяет файлы С ДИСКА одноразовым контейнером, а при
  расхождении инода контейнер пересоздаётся до reload.
- loki-config.yml — тот же пофайловый маунт, перезагрузки у Loki нет:
  пересоздание при расхождении инода.
- Датасорсы Grafana применяются только при старте: POST
  /api/admin/provisioning/datasources/reload, отказ роняет деплой.

Общий шаг — ops/metrics/recreate-stale-mount.sh: пересоздаёт только при
расхождении инода и перепроверяет после; тесты исполняют его с подставным
docker. Гейт берёт пофайловые маунты из docker-compose.metrics.yml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:42:35 +05:00
e2b1a8adee fix(tradein/dedup): дома с районом, приклеенным к номеру («17Ар-н Академический»), сливаются со своими двойниками (#1772)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Failing after 24s
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 6m47s
Канон-ключ tradein_canon_addr не срезал район, приклеенный к номеру дома без
запятой (наследие слипа «29р-н» из #1773): «ул. Евгения Савкова, 17Ар-н
Академический» давал «евгениясавкова17арнакадемический», а «44Бр-н» терял
литеру (токен-срез принимал «бр» за бульвар). Двойник «ул. Евгения Савкова,17А»
с таким домом в один кластер не попадал, и перепись остатка его не видела.

Миграция 311: тело 147 плюс шаг «<цифра>[литера]р-н <название>» → «<цифра>[литера]»
сразу после lower. Якорь на цифре обязателен: без него «мкр-н Кутузовский, 2»
превращается в «мк2». Прод 17.09 (read-only): канон меняется у 44 из 49 159
домов, у 20 появляется двойник в пределах 250 м, ГАР-сторона не затронута
(0 из 3,27 млн строк с шаблоном).

Тест по значению на функции, установленной миграциями, и сквозное слияние:
склеенный адрес и чистый двойник сливаются, «17» без литеры остаётся отдельным.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:42:08 +05:00
bc0ef9536e fix(glitchtip): медленный отказ Telegram отвечает 502 до таймаута GlitchTip (#3157)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 28s
CI / changes (pull_request) Successful in 33s
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 / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m25s
Синхронная пересылка алерта была ограничена таймаутом одного HTTP-запроса
(8 с), а попыток после ретранслятора (#3471) больше одной: relay, прямой путь,
пауза 2 с, повтор. 16.09.2026 16:42 UTC отказ шёл медленно, GlitchTip
(aiohttp ClientTimeout total=10) оборвал запрос на 10-й секунде, Caddy записал
status=0, а хендлер досчитал 200 уже разорванному клиенту: uvicorn такой ответ
выбрасывает вместе со строкой access-log. Исход у отправителя и приёмника
расходился, 502 и фоновая доставка не срабатывали.

Вся синхронная попытка теперь под asyncio.timeout(7 с). TimeoutError идёт тем
же путём, что TelegramError: logger.exception, 502, фоновая доставка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:40:56 +05:00
ceb2e5972d fix(tradein/dedup): ФИАС-проход схлопывания домов больше не сливает дома за сотни километров (#2690)
ФИАС-проход house_dedup_merge шёл без гео-стража (#2187: «общий ФИАС-UUID и
есть здание»). Посылка неверна: house_fias_id — ответ DaData на НАШУ строку
адреса, и «улица Кирова, 4» без города получает тот же UUID, что и дом в
другом городе. Прогон 05.09 (house_merge_log, merge_pass='fias'): 408 слияний,
129 дальше 250 м, 53 дальше 3 км, максимум 362 км, у 67 нет координат на одной
из сторон.

Теперь у обоих проходов один и тот же страж (250 м, координаты у обеих сторон),
параметр apply_geo_guard удалён — асимметрию нельзя вернуть флагом.

Тест по значению на живой Postgres: тот же ФИАС в 5 км и с пустыми координатами
не сливается, тот же ФИАС в 10 м сливается, журнал пишет geo_guard=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:39:34 +05:00
31c31f8f82 МЕРА: якорь «тот же дом» и IMV-blend — константы движка, не настройки (#2381)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 25s
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 2m35s
CI Trade-In / backend-tests (pull_request) Successful in 6m20s
Пять булевых флагов кластера сняты ещё в #2475. Оставшиеся 14 числовых полей
Settings перенесены в estimator.py константами с теми же значениями:
IMV_BLEND_WEIGHT 0.5, IMV_BLEND_THRESHOLD 1.15, SB_MIN_COMPS 4, SB_AREA_SIGMA
0.18, SB_ROOMS_MATCH_BOOST 1.6, SB_FLOOR_SIGMA 0.25, SB_GUARDRAIL_TOL 0.05,
SB_MAD_K 3.5, SB_MAD_K_SMALL_N 2.5, SB_SMALL_N_THRESHOLD 10,
ANCHOR_TIER_C_CORRIDOR_MULT 1.5, FSD_K 1.65, SB_GATE_MIN_N 3, SB_GATE_MAX_FSD
0.20. На проде 17.09 все 14 равны дефолтам, ENV-оверрайдов нет. Мёртвая
проверка `tier_c_mult > 0` (константа против нуля) убрана.

Тесты подменяют SB_MIN_COMPS на модуле вместо поля settings. Реплей бэктеста
по сделкам побитово тот же, срабатываний якоря 985, IMV-blend 3, low-conf
гейта 11 — как на main. Шесть порогов (rooms_boost, floor_sigma,
guardrail_tol, tier_c_mult, fsd_k, gate_max_fsd) не ловит ни один
поведенческий тест и ни гейт, их держит только тест боевых значений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:44 +05:00
1fe383f935 fix(tradein): коридор ДКП при якоре того же дома помечен как не вошедший в цену (#3466)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 20s
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 2m25s
CI Trade-In / backend-tests (pull_request) Successful in 7m2s
Кламп headline к коридору ДКП выключается не только малым числом сделок
(advisory_only, #3452), но и якорем Tier A: _apply_corridor_clamp его exempt,
radius-floor требует anchor_tier is None. Ревьюер #3462 воспроизвёл n=20,
advisory_only=False, headline 202 100 против потолка 140 000 — подписи нет.

- Признак — analog_tier == "same_building" (уже структурный в POST).
  corridorAdvisoryNote принимает тир и при same_building говорит
  «справочно: цена посчитана по аналогам в этом же доме — коридор её не
  ограничивает»; оба вызова (v1 HeroSummary, v2 mappers) передают тир.
- GET-rehydrate терял analog_tier (колонки нет) — якорный тир теперь
  восстанавливается из подписи якорного блока в confidence_explanation
  через общую константу (analog_tier_from_explanation), как радиус в #2632.
  Разбор по всей фразе: радиусный тир S пишет «(аналоги из того же дома)»,
  на проде таких строк 4.
- Тесты floor не доходили до floor: три лота уводили в #oblast-E, headline
  брался из медианы коридора. На main с полностью выключенным floor файл
  зелёный. Лотов шесть, ожидания точные, добавлен кейс n = min_n − 1.
- Полоса маркера corridor_advisory_zone — n = 1..9, не 3..9: уличный коридор
  отдаётся с одной сделки. Формулировки поправлены, тест на n=2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:35 +05:00
26a7f1c163 fix(metrics): тревога, когда бэкенд Меры лёг, а лэндинг ещё открывается (#2214)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 21s
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 24s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m23s
CI / backend-tests (pull_request) Successful in 8m17s
Мёртвый или зависший tradein-backend не давал ни одного сигнала вне Poincare.
GlitchTip-монитор 6 смотрит на лэндинг meraocenka.ru, а его отдаёт фронт из
пререндер-кэша (x-nextjs-cache: HIT) — при мёртвом бэкенде там 200. Монитор 2
(gendsgn.ru/health) проверяет бэкенд Птицы. AppHighErrorRate и
AppHighLatencyP95 считают метрики самого бэкенда и при его смерти молчат.

Новое правило MeraBackendDown: up{job="app",app="mera"} == 0 или серия
пропала, 5 минут, severity critical + host apps — маршрут telegram-clients,
тема 158. Считается и доставляется на Beget. Порог 5 минут откалиброван по
истории up за 26.08–17.09: 140 провалов, самый длинный — 4 нулевые точки
(~2 минуты, окна деплоя).

Юнит-тесты promtool: мёртвый бэкенд — тревога; окно деплоя и упавшая Птица —
тишина; цель пропала из скрейпа — тревога.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:14 +05:00
883c741a63 МЕРА: кламп expected_sold ≤ asking без выключателя, пороги #1795 — константы (#2380)
Флаг estimate_expected_sold_le_asking снят: оба клампа (ratio > 1.0 и
повторный после хедоники) безусловные. На проде 17.09 флаг = True во всех
трёх контейнерах, ENV-оверрайда нет, фикстура бэктеста захвачена с True.
Флаги corridor_clamp/radius_floor enabled сняты ещё в #2475.

Числовые пороги перенесены с прежними значениями: CORRIDOR_CLAMP_SLACK 0.40,
RADIUS_FLOOR_FACTOR 0.8, OUTLIER_SMALL_N_THRESHOLD 15, OUTLIER_TUKEY_K_SMALL 1.0
— в estimator.py; CORRIDOR_CLAMP_MIN_N 10 — в app.core.config рядом с
LISTINGS_FRESH_DAYS, потому что его же читает DkpCorridor.advisory_only (#3452),
а схема не должна тянуть estimator. Мёртвая проверка `tukey_k_small < 1.5`
(константа против константы) убрана. Сегментный множитель (#2255) не тронут,
порядок операций прежний.

Тесты: OFF-тесты клампа удалены; тест потолка хедоники берёт ratio 0.70, при
котором кламп не срабатывает (0.70 × 1.30 = 0.91), вместо выключения клампа.
Реплей бэктеста по сделкам побитово тот же (бизнес 169, элит 5, премиум 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:33:56 +05:00
0ff2703032 МЕРА: пороги квартального индекса — константы движка, не настройки (#2379)
Единственный переключатель estimate_quarter_index_enabled снят ещё в #2475.
Оставшиеся шесть числовых полей Settings (min_n_deals 10, match_skip_ratio 0.6,
max_for_small_n 2.0, small_n_threshold 50, factor_min 0.6, factor_max 1.8)
перенесены в estimator.py константами QUARTER_* с теми же значениями и
комментариями #764/#859; бэктест берёт QUARTER_INDEX_MIN_N_DEALS из модуля.
На проде 17.09 все шесть равны дефолтам, ENV-оверрайдов нет.

Замороженная фикстура квартальный индекс не применяет ни разу (0 из 1600
сделок), а поведенческие тесты не краснеют при подмене трёх порогов из шести,
поэтому добавлен tests/test_1970_estimator_constants.py: боевые значения,
снятые с прода, и проверка, что поле не вернулось в Settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:31:12 +05:00
45bd47fdd7 МЕРА: дедуп аналогов и пороги ручной оценки без выключателей (#2378)
Флаг estimate_dedup_analogs_enabled снят: кросс-source дедуп работает всегда.
На проде 17.09 флаг = True во всех трёх контейнерах (backend/scraper/tgbot),
ENV-оверрайда нет; фикстура бэктеста захвачена с True, поэтому пин флага в
реплее и monkeypatch в гейте больше не нужны.

Числовые пороги estimate_wide_corridor_threshold и три
estimate_manual_review_* перенесены в estimator.py константами модуля с
прежними значениями (1.2 / 20 000 000 / 1.9 / 250 000). _manual_review больше
не принимает settings. Осиротевшие комментарии Settings к уже снятым в #2475
флагам (#1871 P1.2, P2 radius-dedup) удалены.

Тесты: OFF-тест дедупа удалён; два теста, пинившие дедуп OFF ради изоляции,
получили разные площади у аналогов (разные физлоты). Регрессионный гейт и
реплей бэктеста по сделкам (1600, из них 615 радиусных) побитово те же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:29:26 +05:00
08216e4c43 feat(mera/estimator): порог и окно этажности Tier H — в настройки, для бэктеста (#3234)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 23s
CI / changes (pull_request) Successful in 24s
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 6m30s
Tier H («тот же класс дома») отдаётся только при ≥5 комплах, иначе молча уходит в
Tier W без фильтра по классу. Прод, Loki 30 сут (комментарий 12.09): 61 из 86
попыток не доживает, в 31 из них в своей полосе 0 комплов. Какой вариант лучше —
порог 5→4, окно этажей ±30%→±50% или оба — решает только MAPE бэктеста на сделках,
а оба числа были литералами: сравнить варианты без правки кода было нельзя.

estimate_tier_h_min_comps (5) и estimate_tier_h_floors_tol (0.30) в Settings,
_fetch_analogs берёт их оттуда. Дефолты дают те же tf_min/tf_max, что литералы
(сверено для total_floors 1..199), поведение прода не меняется. Бэктест варианта:
docker exec -e ESTIMATE_TIER_H_MIN_COMPS=4 -e ESTIMATE_TIER_H_FLOORS_TOL=0.5 …

Дефолт не меняю: бэктест вариантов не прогнан (нужен деплой этих ручек).

Refs #3234

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:28:55 +05:00
56f0cdbdf7 fix(mera/lead): о новой заявке узнаёт ответственный, конверсия «оценка → заявка» на дашборде
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 21s
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 3m53s
CI Trade-In / backend-tests (pull_request) Successful in 6m56s
CI / backend-tests (pull_request) Successful in 8m25s
#1971, два невыполненных пункта DoD.

1. Уведомления не было. Заявка с результата оценки только ложилась в
trade_in_leads (docstring прямо называл уведомление «вне scope»). На проде
17.09: 4 заявки, notified_at пуст у всех, последняя 12.07. Теперь после ответа
клиенту фоновая задача шлёт сообщение в support-топик тем же ботом, что и
веб-чат поддержки, и при успехе ставит notified_at. Отказ Telegram не меняет ни
ответ (200), ни сохранённый лид — только лог. Телефона в сообщении нет: копию в
Telegram не стирает механизм удаления ПДн, поэтому туда идут id заявки,
пользователь и id оценки.

2. Доли заявок от оценок не было нигде. Панель на продуктовом дашборде: лиды за
7 суток / успешные оценки за 7 суток (знаменатель — только outcome=ok: форма
заявки показывается только при посчитанной оценке). Выражение проверено на
боевом Prometheus 17.09: 0 / 91.008 = 0.

Closes #1971

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:27:44 +05:00
4dffb2d1fb fix(mera/лендинг): итог игры «Проверьте себя» говорит, что квартиры взяты из отобранной полосы (#2903)
All checks were successful
CI Trade-In / frontend-checks (pull_request) Successful in 2m32s
CI Trade-In / backend-tests (pull_request) Successful in 7m1s
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 17s
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
Раунды игры — строки витрины, а витрина с 12.09 отобрана по ошибке МЕРЫ
(полоса -5..+20 %). Плитка «ошибка МЕРЫ на этих же квартирах» выходила без
оговорки и читалась как точность расчёта: на проде 17.09 медиана |ошибки|
двадцати строк 6,5 %. Лента и таблица «Точность» полосу называли, игра — нет.

Теперь итог печатает медиану по всей сверке (landing-facts) и, если все
строки витрины лежат в полосе (allWithinBand, как у ленты и таблицы),
говорит, что квартиры из отобранной полосы, а не из всей сверки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:27:33 +05:00
9f11b8ed69 fix(mera/estimator): дедуп склеивает нумерованные улицы через дефис (#2291, класс 4)
Регексы названия улицы в _parse_street_house пускали ведущую цифру только через
пробел («8 Марта»), а через дефис нет. «40-летия Комсомола», «22-го Партсъезда»,
«3-го Интернационала» разбирались так:
- avito «ул. 40-летия Комсомола,32Г» и yandex — ('', ''), дедупа нет вовсе;
- cian «мкр. ЖБИ, улица 40-летия Комсомола, 8А» — ('mkr жби', '40'): все дома
  улицы получали один ключ и дом «40», разные квартиры с одинаковыми
  этажом/площадью/ценой сливались в одну;
- domklik «60-летия Октября проспект» — ('октября', …).

Фикс: (?:\d+\s+)? → (?:\d+(?:\s+|-))? в _DEDUP_STREET_NAME_RE и
_DEDUP_STREET_NAME_SUFFIX_RE. На 5310 живых адресах прода с «цифра-буква»
меняется разбор у 730 объявлений, все новые токены — реальные нумерованные
улицы (14 штук, от «40-летия комсомола» до «2-ая новосибирская»).

Бэктест-гейт: baseline перегенерирован. В реплее дедуп включён (как на захвате),
поэтому правка разбора сдвигает пул: изменились 99 из 1600 сделок, у 48 ошибка
меньше, у 51 больше. expected_sold MAPE 15.88→15.92, покрытие вилки 1056→1057,
headline не сдвинулся.

Refs #2291

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:25:50 +05:00
71d60ff13e fix(mera/витрина): пустой прогон витрины сделок больше не считается успешным (#3511)
Обработчик landing_showcase_deals безусловно ставил прогону done. У счётчиков
витрины (considered/eligible/written) нет результатного ключа кита, поэтому
сводка просроченных судит её только по статусу: прогон с written=0 обнулял
часы свежести так же, как удачный, а страница тем временем теряла таблицу.

Теперь written=0 — mark_failed с причиной и logger.error. Тест идёт путём
сводки: обработчик -> freshness_rows -> stale_sources; пустой последний
прогон при старом непустом даёт витрину в тревоге, непустой — нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:24:20 +05:00
f1f0d297cf МЕРА v2: клик по «Предыдущие оценки» после отправки показывает выбранную оценку (#2425)
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 / 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 1m19s
После отправки оценки freshResult оставался выставленным, и клик по строке
истории ничего не менял на экране: useEstimate получал null (восстановление
по id вообще не запускалось), а estimate и currentEstimateId брались из
только что посчитанного результата. onSelectEstimate теперь сбрасывает
freshResult так же, как это делает handleNew.

Тест рендерит страницу с заглушками хуков: submit, затем клик по другой
строке истории — в useEstimate уходит id строки, HeroBar и форма получают
её id и адрес. Без правки тест красный (useEstimate вызван с null).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:23:47 +05:00
34642e1dd5 Merge pull request 'Метрики: экспортёр Celery наконец поднимается, тревоги про воркеров и память перестают врать' (#3547) from fix/3493-celery-exporter-memory-alert into main
All checks were successful
Deploy Metrics / server (push) Successful in 45s
Deploy Metrics / agent-infra (push) Successful in 24s
Deploy Metrics / agent-apps (push) Successful in 39s
2026-09-17 07:05:41 +00:00
251ca98c5c fix(metrics): экспортёр Celery поднимается, тревоги про воркеров и память говорят правду (#3493)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 2m37s
CI / backend-tests (pull_request) Successful in 7m8s
Две тревоги шумели в канале каждую ночь и каждые три часа, обе врали текстом.

NoActiveCeleryWorkers горела с 12.09 без перерыва при живом воркере
(`Up 29 hours`, 15 задач за 10 минут). Экспортёр не поднимался ни разу:
тега danihodovic/celery-exporter:0.13.0 на Docker Hub не существует
(`pull` → `not found`, логи джобы agent-apps 12.09 и 16.09), а под
`set -e` упавший pull обрывал ВСЮ джобу — с 12.09 на Poincare не доезжало
ничего из агента метрик: ни этот экспортёр, ни redis-exporter, ни новый
конфиг Alloy. Второй дефект за первым: флага `--queue` в 0.12.2 нет,
опция зовётся `--queues` — с одним исправленным тегом экспортёр упал бы
при старте. Тег и флаг сверены с исходником (src/cli.py, src/exporter.py)
и пробой на боевом брокере: временный контейнер 0.12.2 отдал
`celery_worker_up{hostname="687f9212bebb"} 1.0`. Адрес брокера не тронут —
алиас `gendesign-redis` на сети gendesign_shared существует и резолвится.

Правило разделено на две новости. NoActiveCeleryWorkers — только когда
экспортёр жив, а воркеров нет. QueueExporterDown (warning) — когда не
отвечает сам экспортёр, с текстом «авария наблюдаемости, не продукта».
Заодно прежняя ветка `count(celery_worker_up == 1) == 0` не сработала бы
никогда: count() от пустого вектора пуст, мёртвый воркер она пропускала.

ContainerNearMemoryLimit на tradein-postgres: working_set включает активный
кэш страниц, ночные сканы поднимают его к потолку. Анонимная память за
неделю 11–170 МБ из 3 ГБ, oom_kill = 0 за 21 сутки — «дальше OOM-kill» был
ложью. Добавлено второе условие: анонимная память > 50 % лимита.
Калибровка ЭТИМ выражением по 14 суткам истории: tradein-postgres 5 → 0
эпизодов, tradein-browser 2 → 2 (06.09 и 12.09, rss до 95 % — настоящие).

Юнит-тесты правил (ops/metrics/prometheus/tests/infra_test.yml, 6 случаев,
каждый уже случался на проде) исполняются деплоем перед reload. На старых
правилах promtool test rules → rc=1 (6 расхождений), на новых → rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:22:15 +05:00
1db6612843 Развести окна ДомКлика: область не должна стартовать вместе с ЕКБ (#3546)
All checks were successful
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) Successful in 3m59s
Deploy Trade-In / build-backend (push) Successful in 41s
Deploy Trade-In / deploy (push) Successful in 7m50s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m44s
2026-09-17 05:56:34 +00:00
1452e63eec Регулярный сбор ДомКлика по Москве и области (#3545)
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 3m51s
Deploy Trade-In / build-backend (push) Successful in 1m49s
Deploy Trade-In / deploy (push) Successful in 7m53s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s
2026-09-17 04:46:37 +00:00
8ebd63780f Коэффициент asking→sold приводится к сегодняшнему дню (#3544)
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 3m47s
Deploy Trade-In / build-backend (push) Successful in 1m8s
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-16 20:05:18 +00:00
ad239db034 Регулярный сбор по Москве и области (#3541)
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 4m13s
Deploy Trade-In / build-backend (push) Successful in 44s
Deploy Trade-In / deploy (push) Successful in 1m16s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-16 19:25:09 +00:00
1c039b358c Множители доверительного интервала по регионам (#3540)
All checks were successful
Deploy Trade-In / changes (push) Successful in 17s
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 3m45s
Deploy Trade-In / build-backend (push) Successful in 1m10s
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 1m44s
2026-09-16 19:08:49 +00:00
c63c48eea2 Не выдавать цену, когда рядом нет ни одного аналога (#3537)
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 3m52s
Deploy Trade-In / build-backend (push) Successful in 1m14s
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 1m40s
2026-09-16 17:16:25 +00:00
576dba9b24 Карточка «не хватает данных» вместо статичной заглушки (#3539)
All checks were successful
Deploy Trade-In / changes (push) Successful in 18s
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 Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / deploy (push) Successful in 1m3s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-16 17:01:54 +00:00
31b207be93 Выключить гедоническую коррекцию — она стала чистым занижением цены (#3538)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 6m55s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m47s
Deploy Trade-In / changes (push) Successful in 19s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m2s
Deploy Trade-In / test (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m9s
2026-09-16 16:43:12 +00:00
4af1b14225 22 города Московской области в покрытии (#3536)
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 16s
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-16 16:42:06 +00:00
0c6b5a4548 Замер калибровки доверительного интервала по регионам (#3535)
Some checks failed
Deploy Trade-In / deploy (push) Successful in 7m53s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
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 1m14s
2026-09-16 16:28:18 +00:00
1bd5283aa6 fix(caddy): закрыть генерацию архивов Forgejo — она съедала диск (#3534)
Some checks failed
Deploy Infra Host / sync-infra-host (push) Successful in 8s
Deploy / changes (push) Successful in 14s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Has been skipped
Deploy / deploy-caddy (push) Successful in 5s
Deploy / deploy-status (push) Successful in 1s
Deploy Metrics / server (push) Successful in 46s
Deploy Metrics / agent-apps (push) Failing after 14s
Deploy Metrics / agent-infra (push) Successful in 24s
Deploy / perimeter-smoke (push) Successful in 1m41s
2026-09-16 14:22:18 +00:00
6da7de06a4 Черновик регулярного сбора по Москве и области: якоря, ID площадок, гео-профили ДомКлика (#3533)
All checks were successful
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) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 1m49s
Deploy Trade-In / deploy (push) Successful in 4m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m44s
ДомКлик: DomClickGeoProfile вместо зашитых _EKB_ADDRESS_GUID/_EKB_AREA_ID; GUID Москвы
и области проверены живьём, aids вне ЕКБ не нужен, гард по bbox профиля. Страница
прогрева 77/50 — апексный domclick.ru: субдомены msk./moskovskaya-oblast. отдают 301.

Циан/Авито/Яндекс: CityLocation.cian_host, три новых скоупа (moskva, moskovskaya_oblast,
moskva_i_mo), avito_slug_is_region, city=NULL у мультигородских скоупов. Неизвестный слаг
теперь падает с ValueError вместо молчаливого отката на Екатеринбург.

Якоря: Москва — сетка 25 точек под radius_m=8000; область — 22 города-спутника
(10 добраны из Nominatim) плюс 22 кластера лот-массы. Замер на проде: города радиусом
10 км дают 73.8% лот-массы области, вместе с кластерами — 95.2%.

Ценовые коридоры: планировщик plan_price_corridors со статистикой усечения плюс
BisectionStats в живом движке. Провайдеры их пока не передают — отдельный заход.

Расписаний scrape_schedules для 77/50 в этом PR нет: они пойдут после первого ручного
прогона, подтверждающего живость профилей.
2026-09-15 17:51:30 +00:00
ce60fc214d Геокодирование сделок области: ключ (регион, НП, улица) вместо голой улицы (#3532)
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 1m20s
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 1m43s
Центроид строился по названию улицы без города — в области это схлопывало одноимённые
улицы разных городов. Ключ стал (region_code, населённый пункт, улица); НП берётся из
типа сегмента адреса, из реестра городов региона или из deals.city; при пустом НП ключ
у области отбрасывается, а не склеивается с чужим городом.

Фильтры по региону добавлены в SELECT кандидатов, в запрос домов и в UPDATE. Новый
--max-spread-km (5 км) отбрасывает бакет с разбросанными домами.

geocoder.py: поддержка региона 50 (маркер «московская» — намеренно не «москва»,
DaData-имя «Московская», новое поле Region.has_city_core=False, чтобы к адресу области
не приклеивался суффикс главного города).
2026-09-15 17:28:09 +00:00
39996ef3fd Коэффициент asking→sold вне ЕКБ считается на согласованной географии (#3531)
All checks were successful
Deploy Trade-In / changes (push) Successful in 18s
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 3m58s
Deploy Trade-In / build-backend (push) Successful in 1m25s
Deploy Trade-In / deploy (push) Successful in 2m9s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
Обе стороны раскладываются по одной сетке 0.1°x0.2°, медианы внутри ячейки, в итог
только ячейки с ≥10 сделок И ≥10 объявлений, агрегация взвешена числом сделок.

Замер на проде (SQL исполнен в транзакции с ROLLBACK): регион 50 — 0.8136 вместо
пулового 0.8851, регион 77 — 0.7287 вместо 0.7100. Разнонаправленный сдвиг — подпись
поправки состава. Регион 66 не тронут байт в байт.

Гарды: регион не получает строк при покрытии geom <50%, совпавших ячейках <3 или
попадании в пересечение <50% геокодированных сделок; DELETE идёт в любом случае.
2026-09-15 17:18:05 +00:00
f2c18f5216 Метрика на корпоративном аккаунте и подтверждение прав в Яндекс.Вебмастере (#3530)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 3m57s
Deploy Trade-In / build-backend (push) Successful in 41s
Deploy Trade-In / deploy (push) Successful in 1m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-15 16:48:57 +00:00
19418066c2 Счётчик Яндекс.Метрики 112563104 доведён до сборки публичного контура (#3529)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Successful in 48s
Deploy Trade-In / build-frontend (push) Successful in 2m36s
Deploy Trade-In / test (push) Successful in 3m59s
Deploy Trade-In / build-backend (push) Successful in 38s
Deploy Trade-In / deploy (push) Successful in 8m18s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-15 16:29:32 +00:00
dbf46228fb Дробить плитку Overpass только при перегрузке, а не при отказе сети (#3528)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m29s
Deploy / build-worker (push) Successful in 4m1s
Deploy / deploy (push) Successful in 1m28s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m44s
2026-09-15 07:33:56 +00:00
824de24897 Снятие устаревших объявлений работает только там, где есть пересбор (#3526)
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 4m8s
Deploy Trade-In / build-backend (push) Successful in 1m16s
Deploy Trade-In / deploy (push) Successful in 8m37s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-15 07:32:50 +00:00
967f2b53ba Границы регионов без упрощения, чтобы Куркино не считалось по Екатеринбургу (#3527)
All checks were successful
Deploy Trade-In / changes (push) Successful in 23s
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 1m19s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-15 07:22:31 +00:00
2d2d9ebf17 Свип с чужим регионом падает вместо тихого сбора Екатеринбурга (#3525)
All checks were successful
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 17s
Deploy Trade-In / test (push) Successful in 3m55s
Deploy Trade-In / build-backend (push) Successful in 1m57s
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 1m44s
2026-09-15 07:09:05 +00:00
a83418799c Загрузчик точек интереса берёт регион параметром, а не только Екатеринбург (#3524)
All checks were successful
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 2m21s
Deploy / build-worker (push) Successful in 3m38s
Deploy / deploy (push) Successful in 1m23s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m42s
2026-09-15 07:05:21 +00:00
d7aaa00326 Матч ГАР работает по любому региону, а не только по Екатеринбургу (#3523)
All checks were successful
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 1m12s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m46s
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-15 06:59:01 +00:00
3d114672fe Регион точки резолвится по настоящей границе, а не по прямоугольнику (#3052) (#3522)
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 3m49s
Deploy Trade-In / build-backend (push) Successful in 1m11s
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 1m44s
2026-09-15 06:44:31 +00:00
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 / deploy-caddy (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 14s
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 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 / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
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 / 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 / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Metrics / server (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / agent-apps (push) Failing after 18s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (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 / 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
Deploy Trade-In / test (push) Successful in 6m53s
Deploy Trade-In / build-backend (push) Has been cancelled
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
342 changed files with 30697 additions and 3216 deletions

View file

@ -219,6 +219,34 @@ 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'
# #3486: гейт test_metrics_single_file_mounts.py читает и
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
# экспортёра) без этой строки прошла бы мимо гейта.
- 'docker-compose.metrics-agent.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'
@ -232,10 +260,12 @@ jobs:
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
# не бежали ни разу с момента написания.
#
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
# нужен, в отличие от tradein-лэйна.
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
# ST_DWithin по geography, без расширения его не исполнить. На plain
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
#
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
@ -266,15 +296,14 @@ jobs:
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
#
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
# здесь не нужен вовсе, в отличие от tradein-лэйна.
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
run: |
set -u
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
postgres:16
postgis/postgis:16-3.4
ready=""
for _ in $(seq 1 45); do

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 неизменившимся: он сравнивает
# описание сервиса, а содержимое бинд-маунта в это сравнение не
@ -317,6 +388,15 @@ jobs:
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
fi
# ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ─────────────
# Та же ловушка инода, что у Alertmanager выше, а перезагрузки
# основного конфига у Loki нет вовсе: правка loki-config.yml ложилась
# на диск и не вступала в силу до случайного пересоздания.
# Пересоздаём только при расхождении инода.
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
gendesign-metrics docker-compose.metrics.yml \
loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
@ -335,15 +415,99 @@ jobs:
fi
fi
# ── Приёмка ────────────────────────────────────────────────────
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
# перечитывает ────────────────────────────────────────────────
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
# docker compose сравнивает описание сервиса, а НЕ содержимое
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
# со старым конфигом сколько угодно — на проде дошло до 16 суток
# незамеченными (#3467): lastConfigTime совпадал со startTime
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
#
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
# файла: после `git reset --hard` за путём в контейнере остаётся
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
# --web.enable-lifecycle включён в compose ради этого шага.
#
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
# контейнером того же образа. `docker exec` в работающий проверял
# бы тот самый старый инод, а не то, что сейчас применится.
# Синтаксически верное правило может врать по смыслу —
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
# отсюда test rules.
if docker run --rm --entrypoint sh \
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
-c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
gendesign-metrics docker-compose.metrics.yml \
prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml
# ── Приёмка ────────────────────────────────────────────────
# После возможного пересоздания выше — ждём оба процесса,
# которые дальше перечитывают конфиг по HTTP.
for i in $(seq 1 30); do
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \
&& docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then
break
fi
sleep 3
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
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
# ── Grafana: датасорсы применяются только при старте (#3467) ────
# Каталог provisioning смонтирован целиком, новый datasources.yml
# контейнер видит сразу. Дашборды провайдер пересканирует сам
# (updateIntervalSeconds: 30), а датасорсы — нет: на стенде
# grafana:11.5.1 изменённый url не применился и через 75 с, POST
# на ручку ниже применил сразу (замер в PR #3475). Без этого шага
# правка датасорса лежала бы на диске без эффекта при зелёном деплое.
#
# Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя
# он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем
# отдельно, чтобы «200 не от той ручки» не сошло за успех.
GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \
'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \
2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD"
case "$GRAFANA_RELOAD" in
*"Datasources config reloaded"*)
echo "Grafana: датасорсы перечитаны."
;;
*)
echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта."
exit 1
;;
esac
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps:
runs-on: ubuntu-latest
@ -435,6 +599,21 @@ jobs:
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
# ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ──
# PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а
# `git reset --hard` пишет правку новым инодом: без пересоздания
# экспортёр продолжает отдавать старые запросы при зелёном деплое.
# Пересоздаём только при расхождении инода. Под гейтом профиля:
# `up` с явным именем сервиса включает его профиль сам.
if [ -n "$EXPORTER_PROFILE" ]; then
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml
fi
agent-infra:
runs-on: ubuntu-latest
needs: server
@ -504,3 +683,10 @@ jobs:
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
# queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486).
if [ -n "$EXPORTER_PROFILE" ]; then
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml
fi

View file

@ -511,23 +511,36 @@ 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-правка на проде).
# NEXT_PUBLIC_YM_ID — счётчик Яндекс.Метрики публичного контура.
# Живёт на корпоративном аккаунте info@meraocenka.ru, там же, где
# Вебмастер: счётчик и права на сайт на одном аккаунте связываются
# автоматически, на разных — только через письмо-подтверждение
# владельцу счётчика. Прежний номер 112563104 был заведён на личном
# аккаунте подрядчика и на сайт не попал ни разу, данных не потерял.
# NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав в
# Яндекс.Вебмастере, отдаётся мета-тегом (mera-public/layout.tsx).
# Токен привязан к аккаунту: у другого аккаунта он будет свой.
# Удалять его после подтверждения НЕЛЬЗЯ — Вебмастер перепроверяет
# права и снимает их, если тег исчез.
# Литералы, а не secret'ы: оба значения и так уходят в разметку
# каждой страницы, прятать нечего, а secret'ом их не проверить
# глазами в диффе.
# NEXT_PUBLIC_GA_ID/GOOGLE_VERIFICATION — ПОКА ПУСТЫЕ: ресурс GA4 и
# права в Search Console ещё не заведены. Пустая строка =
# соответствующий тег НЕ рендерится вообще (контракт фронта, см.
# тот же 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_YM_ID=112672927
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
@ -552,9 +565,9 @@ 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_YM_ID=112672927
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |

View file

@ -77,7 +77,7 @@ on:
# исполнял бы старую версию — молча и без единого сигнала.
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
# backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное
# backup.sh, restore-drill.sh, restore.sh — точечное
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это
# снова забыли бы (см. как этот самый комментарий выше был точечным про
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
@ -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

@ -1910,17 +1910,19 @@ def trigger_ekburg_permits(
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
_WAF_COOLDOWN_GUARD_MSG = (
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
"передай i_understand_waf_risk=true в теле запроса."
# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и
# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера
# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов
# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже
# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому
# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307).
_DOMRF_BLOCK_GUARD_MSG = (
"Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт "
"этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), "
"beat schedule для этого таска выключен по той же причине. Ожидание блокировку "
"не снимает, запуск отсюда даст только отказы. Передавай "
"i_understand_waf_risk=true, только когда сбор идёт через прокси и kn-прогон "
"принят по числу строк (#3307)."
)
@ -1952,20 +1954,20 @@ def trigger_kn_catalog_objects(
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint для ad-hoc
запуска (smoke-тест после деплоя или повторный pass для свежесозданных
объектов до next beat fire).
Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
Этот endpoint для ad-hoc запуска (smoke-тест или повторный pass для
свежесозданных объектов).
- max_objects=None дефолтный лимит таска (300).
- max_objects=3 smoke-тест.
- force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
re-trigger без явного подтверждения оператора запрещён.
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
запуск без явного подтверждения оператора запрещён (условие #3307).
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
@ -2022,12 +2024,12 @@ def trigger_kn_catalog_flats(
- max_flats=3 smoke-тест.
- force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
того же WAF-бана.
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
блокировкой StormWall (условие #3307).
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats

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

@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel):
"""
objects_with_velocity_data: int # число комплексов с velocity-данными
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
objects_total_in_radius: int # число комплексов после exclude/filter (знаменатель coverage)
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —

View file

@ -587,19 +587,6 @@ class NSPDBulkClient:
)
return results
async def get_territorial_zones_in_bbox(
self,
bbox: tuple[float, float, float, float],
*,
grid_n: int = 7,
) -> list[dict]:
"""Grid-walk WMS GetFeatureInfo для layer 875838 (ПЗЗ территориальные зоны).
Returns: list of feature dicts с полями id, geometry, properties.
Дедуплицирует по feature id.
"""
return await self.get_features_in_bbox_grid(875838, bbox, grid_n=grid_n)
# ── 4. list_objects_in_building ───────────────────────────────────────────
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
# Готов для per-building помещения/парковка фазы.

View file

@ -19,7 +19,6 @@ Resumable: phase_state в cadastre_jobs показывает прогресс.
from __future__ import annotations
import hashlib
import json
import logging
from collections.abc import Callable
@ -260,10 +259,8 @@ async def harvest_quarter(
update_progress(done_progress)
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
# Bug #1583: кварталы с broken/NULL geom дают quarter_bbox_3857() == None →
# grid-walk следующего прогона молча пропускается. Чиним geom из snapshot.
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
if stats_features:
upsert_quarter_stats(db, quarter, stats_features[0])
@ -278,25 +275,6 @@ async def harvest_quarter(
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
db.commit()
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
quarter_bbox = quarter_bbox_3857(db, quarter)
if quarter_bbox is not None:
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
try:
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
tz_count = _save_territorial_zones(db, quarter, tz_features)
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
raise
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
@ -679,7 +657,7 @@ def upsert_features(
cat = feature.category_id
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
# квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234):
# квартала. Зеркалит _grid_walk_category:
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
try:
@ -1476,114 +1454,6 @@ def upsert_quarter_stats(
)
# ── cad_territorial_zones upsert ────────────────────────────────────────────
def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) -> int:
"""UPSERT territorial_zones features в cad_territorial_zones по zone_id.
Args:
db: SQLAlchemy session.
quarter_cad: кадастровый номер квартала (3 сегмента).
features: list of raw feature dicts от get_features_in_bbox_grid.
Returns:
Количество успешно upserted строк.
"""
inserted = 0
for f in features:
props: dict = f.get("properties") or {}
geom = f.get("geometry")
geom_geojson: str | None = json.dumps(geom) if geom else None
# zone_id — NSPD feature id или стабильный fallback на основе md5 от properties.
# md5 гарантирует идемпотентность между runs (счётчик inserted сбрасывается).
_raw_id = props.get("id") or props.get("zone_id") or f.get("id")
if _raw_id:
zone_id = str(_raw_id)
else:
_props_hash = hashlib.md5(
json.dumps(props, sort_keys=True).encode("utf-8")
).hexdigest()[:12]
zone_id = f"{quarter_cad}_{_props_hash}"
zone_code = (
props.get("zone_code") or props.get("zone_index") or props.get("reg_numb_border")
)
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
permitted_use = props.get("permitted_use") or props.get("vri")
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
# Зеркалит фильтр upsert_zouit (~1196).
geom_type = geom.get("type") if isinstance(geom, dict) else None
if geom_type not in ("Polygon", "MultiPolygon"):
if geom_type:
logger.info(
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
" — geom=NULL",
zone_id,
geom_type,
)
geom_geojson = None
try:
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
# db.execute() в этом loop — outer tx гарантирована.
with db.begin_nested():
db.execute(
text("""
INSERT INTO cad_territorial_zones
(quarter_cad, zone_id, zone_code, zone_name,
permitted_use, raw_props, geom)
VALUES (
CAST(:quarter_cad AS text),
CAST(:zone_id AS text),
CAST(:zone_code AS text),
CAST(:zone_name AS text),
CAST(:permitted_use AS text),
CAST(:raw_props AS jsonb),
CASE WHEN CAST(:geom AS text) IS NOT NULL
THEN ST_Multi(
ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
4326
)
)::geography
ELSE NULL
END
)
ON CONFLICT (zone_id) DO UPDATE SET
zone_code = EXCLUDED.zone_code,
zone_name = EXCLUDED.zone_name,
permitted_use = EXCLUDED.permitted_use,
raw_props = EXCLUDED.raw_props,
geom = EXCLUDED.geom,
fetched_at = NOW()
"""),
{
"quarter_cad": quarter_cad,
"zone_id": zone_id,
"zone_code": zone_code,
"zone_name": zone_name,
"permitted_use": permitted_use,
"raw_props": json.dumps(props, ensure_ascii=False),
"geom": geom_geojson,
},
)
inserted += 1
except Exception as e:
logger.warning(
"_save_territorial_zones: upsert failed zone_id=%s quarter=%s: %s",
zone_id,
quarter_cad,
e,
)
db.commit()
return inserted
# ── Утилиты ──────────────────────────────────────────────────────────────────

View file

@ -30,11 +30,15 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
_RE_ACT_NUMBER = re.compile(
r"\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
re.UNICODE | re.IGNORECASE,
)
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не
# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12».
_ACT_NUM = r"(\d+(?:\s*[-]\s*[А-Я]{1,3})?)(?![\w/\-])"
_RE_ACT_NUMBER = re.compile(rf"\s*{_ACT_NUM}")
# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY».
_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}")
_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"\s*{_ACT_NUM}\s*$")
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
@ -233,7 +237,9 @@ def extract_izyatie_records(
normalized = _normalize_ocr_text(ocr_text)
# Реквизиты акта из заголовка или текста.
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
act_number = _extract_act_number(doc_title) or _extract_act_number(
normalized, require_act_context=True
)
act_date = _extract_act_date(doc_title) or _extract_act_date(
normalized, require_act_context=True
)
@ -289,9 +295,26 @@ def extract_izyatie_records(
# ── Вспомогательные функции ────────────────────────────────────────────────────
def _extract_act_number(text: str) -> str | None:
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает номер акта.
Без require_act_context (заголовок) первое « N» в тексте.
С require_act_context (тело документа, #2982) — номер ТОГО ЖЕ акта, чью дату
выбирает `_extract_act_date`: «» вплотную после даты, иначе вплотную перед
ней. Первое «» в теле это «Решение Думы 60/1» (Генплан) или «Приказ
Министерства 746-П», а не основание изъятия. Номера рядом с датой нет
None, а не номер соседнего документа.
"""
if not require_act_context:
m = _RE_ACT_NUMBER.search(text)
else:
date_m = _act_date_match(text, require_act_context=True)
if date_m is None:
return None
m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or (
_RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start())
)
if not m:
return None
return re.sub(r"\s+", "", m.group(1))
@ -303,6 +326,17 @@ def _act_context_matches(text: str, pos: int) -> bool:
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None:
"""Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен)."""
for m in _RE_ACT_DATE.finditer(text):
if require_act_context and not _act_context_matches(text, m.start()):
continue
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return m
return None
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
@ -315,18 +349,10 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str |
даты честнее, чем дата чужого документа по ней нельзя ни отфильтровать
актуальные изъятия, ни сверить срок.
"""
for m in _RE_ACT_DATE.finditer(text):
if require_act_context and not _act_context_matches(text, m.start()):
continue
day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
except ValueError:
continue
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return f"{y:04d}-{mo:02d}-{d:02d}"
m = _act_date_match(text, require_act_context=require_act_context)
if m is None:
return None
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
def _extract_purpose(text: str) -> str | None:

View file

@ -31,8 +31,13 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
_RE_ACT_NUMBER = re.compile(
r"\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
r"\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])",
re.UNICODE,
)

View file

@ -1109,9 +1109,6 @@ def get_best_layouts(
id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
raw_objects_total_in_radius = len(all_obj_ids)
# Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо
# со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0).
groups_total_pre_filter = len(group_radius_objects(id_row_dicts))
# Применить exclude / filter из request (и к obj_ids, и к строкам для группировки)
exclude_set = set(request.exclude_competitor_obj_ids)
@ -1132,13 +1129,16 @@ def get_best_layouts(
all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
# Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
# objects_total_in_radius во ВСЕХ ветках = len(complex_groups) (после фильтра):
# UI и PDF печатают его как N в «покрытие P% (Y из N комплексов)», а P считается
# по отфильтрованным (#2464). Число до фильтра — в raw_objects_total.
complex_groups = group_radius_objects(id_row_dicts)
if not all_obj_ids:
return _empty_response(
radius_km=request.radius_km,
time_window=request.time_window,
objects_total_in_radius=groups_total_pre_filter,
objects_total_in_radius=len(complex_groups),
raw_objects_total=raw_objects_total_in_radius,
)

View file

@ -344,21 +344,26 @@ _COMPETITORS_SQL = text("""
-- важна velocity питает forecast). Спатиальный join дёшев, т.к.
-- complexes мал (~1.5k строк с geom) НЕ потому что latest_obj
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT).
--
-- #2962: complex → проект Объектива берём из complex_sources (source=
-- 'objective', строго 1:1), а НЕ из objective_lots.complex_id. Тот проставлен
-- один раз миграцией 76 на загрузке 10.05, а еженедельный
-- 70_parse_objective_raw.py UPSERT'ом по objective_lot_id переписывает
-- project_name и не трогает complex_id под одним complex_id лежат чужие ЖК
-- (прод 17.09: 236 354 из 303 677 строк, у «ЖК Мичуринский» 129 проектов).
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
c.id AS complex_id
cs.source_id AS project_name,
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
-- «Парковый» «Парковый квартал»)
( k.comm_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
FROM latest_obj o
JOIN complexes c
ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND c.canonical_name IS NOT NULL
-- #968: только complexes, у которых ЕСТЬ objective velocity-данные →
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
-- без objective_lots съедал бы матч (prod: +37 наивно +181 так).
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
)
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
@ -369,6 +374,20 @@ _COMPETITORS_SQL = text("""
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
)
JOIN complex_sources cs
ON cs.complex_id = c.id
AND cs.source = 'objective'
-- #968: только complexes, у которых ЕСТЬ objective-данные →
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
-- без objective_lots съедал бы матч (prod: +37 наивно +181 так).
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.project_name = cs.source_id
)
CROSS JOIN LATERAL (
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
) k
WHERE o.comm_name IS NOT NULL
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё spatial-only
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
@ -376,7 +395,13 @@ _COMPETITORS_SQL = text("""
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
) ASC
) ASC,
-- #2962: 1:1 complex → objective-проект держится только на данных
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
-- брал любой, сверка его отвергала, и верный терялся.
name_ok DESC,
cs.source_id
),
mapped AS (
-- PRIMARY (unchanged): 148 явных маппингов velocity для них byte-identical.
@ -384,15 +409,18 @@ _COMPETITORS_SQL = text("""
cm.objective_complex_name
FROM objective_complex_mapping cm
UNION
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а
-- (у комплекса может быть несколько корпус-project_name velocity
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
-- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
-- (корпуса у Объектива corpus_name внутри проекта, не отдельные имена).
SELECT nc.obj_id,
ol.project_name AS objective_complex_name
nc.project_name AS objective_complex_name
FROM nearest_cx nc
JOIN objective_lots ol
ON ol.complex_id = nc.complex_id
AND ol.project_name IS NOT NULL
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит»
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
-- гео-фильтра 3 с против 0.4 с.
WHERE nc.name_ok
),
velocity AS (
SELECT
@ -605,9 +633,11 @@ _SOLD_COUNT_SQL = text("""
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
@ -624,16 +654,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
nearest_cx AS (
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
c.id AS complex_id
cs.source_id AS project_name,
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
-- «Парковый» «Парковый квартал»)
( k.comm_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
FROM domrf_kn_objects o
JOIN complexes c
ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND c.canonical_name IS NOT NULL
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
)
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
@ -644,6 +675,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
)
JOIN complex_sources cs
ON cs.complex_id = c.id
AND cs.source = 'objective'
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.project_name = cs.source_id
)
CROSS JOIN LATERAL (
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
) k
WHERE o.obj_id = ANY(:obj_ids)
AND o.latitude IS NOT NULL
AND o.longitude IS NOT NULL
@ -654,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
) ASC
) ASC,
-- #2962: 1:1 complex → objective-проект держится только на данных
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
-- брал любой, сверка его отвергала, и верный терялся.
name_ok DESC,
cs.source_id
),
gapfill_price AS (
SELECT
@ -662,8 +710,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ol.price_per_m2_rub AS price_per_m2_rub
FROM nearest_cx nc
JOIN objective_lots ol
ON ol.complex_id = nc.complex_id
ON ol.project_name = nc.project_name
AND ol.price_per_m2_rub IS NOT NULL
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
WHERE nc.name_ok
)
SELECT
p.obj_id,

View file

@ -1,12 +1,17 @@
"""Загрузчик OSM POI из Overpass API для site-finder.
Запускается раз в неделю через Celery beat. Поддерживает фильтр
"не старше 2 лет" (требование Максима) last_osm_edit_date.
Запускается раз в неделю через Celery beat (регион по умолчанию ЕКБ, см. DEFAULT_REGION).
Поддерживает фильтр "не старше 2 лет" (требование Максима) last_osm_edit_date.
Параметризован регионом (REGION_BBOX) sync_poi_to_db(region=...) может грузить любой
зарегистрированный bbox, не только ЕКБ; большие bbox автоматически режутся на тайлы
(_bbox_tiles), чтобы не упереться в лимиты одного Overpass-запроса.
"""
import asyncio
import json
import logging
import math
from datetime import date, datetime, timedelta
import httpx
@ -19,6 +24,31 @@ logger = logging.getLogger(__name__)
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
# Прямоугольники продуктовых ядер по региону — источник загрузки POI больше не зашит
# в одну константу (было: только ЕКБ, блок «что рядом» молчал для остальных регионов).
# Site Finder — независимая половина монорепо со своим окружением/БД и НЕ импортирует
# tradein-mvp (у того свой реестр `app/services/regions.py`), поэтому bbox для Москвы
# продублирован явно, а не через кросс-импорт. Значение — bbox_product_core региона 77
# (tradein-mvp/backend/app/services/regions.py REGIONS[77]), пересчитанное в тот же
# (south, west, north, east) порядок, что EKB_BBOX выше. "ekb" остаётся значением по
# умолчанию ВЕЗДЕ (sync_poi_to_db / fetch_overpass) — существующее weekly-расписание
# (tasks.poi_sync.sync_osm_poi_ekb) не передаёт region и не должно молча сменить город.
REGION_BBOX: dict[str, tuple[float, float, float, float]] = {
"ekb": EKB_BBOX,
"msk": (55.55, 37.30, 55.95, 37.90), # (south, west, north, east)
}
DEFAULT_REGION = "ekb"
# Максимальный размер стороны ОДНОГО Overpass-запроса в градусах. У ЕКБ обе стороны
# bbox — ровно 0.25° (проверенный на практике размер: per-category запрос укладывается
# в timeout:30 без 504). Для региона с большей стороной bbox запрос режется на грид
# тайлов такого же порядка вместо одного большого — иначе на плотном городе (Москва на
# порядок плотнее ЕКБ по числу POI) Overpass либо отдаёт 504, либо (хуже) частично
# посчитанный ответ без явной ошибки, и загрузка молча обрежется. Для ЕКБ (0.25×0.25)
# тайлинг даёт РОВНО один тайл, совпадающий с EKB_BBOX бит-в-бит — поведение дефолтного
# региона не меняется.
MAX_TILE_SIDE_DEG = 0.25
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
@ -54,17 +84,119 @@ OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
}
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
def _bbox_tiles(
bbox: tuple[float, float, float, float], max_side_deg: float = MAX_TILE_SIDE_DEG
) -> list[tuple[float, float, float, float]]:
"""Разбить bbox на равномерный грид тайлов со стороной ≤ max_side_deg.
(south, west, north, east) список тайлов того же формата. Для bbox, у которого обе
стороны уже max_side_deg (текущий EKB_BBOX: 0.25×0.25), возвращает список ровно из
ОДНОГО тайла, идентичного входному bbox тайлинг не меняет поведение для ЕКБ.
"""
south, west, north, east = bbox
rows = max(1, math.ceil(round((north - south) / max_side_deg, 6)))
cols = max(1, math.ceil(round((east - west) / max_side_deg, 6)))
lat_step = (north - south) / rows
lon_step = (east - west) / cols
tiles = []
for r in range(rows):
for c in range(cols):
tiles.append(
(
south + r * lat_step,
west + c * lon_step,
south + (r + 1) * lat_step,
west + (c + 1) * lon_step,
)
)
return tiles
def _split_bbox_quadrants(
bbox: tuple[float, float, float, float],
) -> list[tuple[float, float, float, float]]:
"""Разбить bbox на 4 равные четверти (2×2) — используется адаптивным ретраем
_fetch_category, когда сам тайл всё равно оказался слишком тяжёлым для Overpass."""
south, west, north, east = bbox
mid_lat = (south + north) / 2
mid_lon = (west + east) / 2
return [
(south, west, mid_lat, mid_lon),
(south, mid_lon, mid_lat, east),
(mid_lat, west, north, mid_lon),
(mid_lat, mid_lon, north, east),
]
# Живой замер 2026-09-13: uniform-тайл 0.25×0.25 (размер ЕКБ) для category=bus_stop в
# Москве отдал 504 Gateway Timeout ОДНИМ тайлом (12 181 bus_stop во всём продуктовом
# ядре — уже больше, чем ВСЕ 14 категорий ЕКБ вместе, 4 850). Единый "правильный" размер
# тайла под все 14 категорий Москвы заранее не подобрать — плотность по городу сильно
# неравномерна (плотный центр / разреженная периферия), а у Overpass нет заголовка с
# "это частичный ответ" — единственный надёжный сигнал перегруза — HTTP-ошибка/таймаут.
# Поэтому вместо фиксированного маленького тайла — АДАПТИВНОЕ дробление: тайл, на
# котором per-category запрос дважды падает, дробится на 4 четверти и каждая
# перезапрашивается рекурсивно (до RECURSIVE_SPLIT_MAX_DEPTH). Для ЕКБ recursion
# НИКОГДА не срабатывает (единственный тайл исторически всегда отвечал 200) — поведение
# дефолтного региона не меняется.
RECURSIVE_SPLIT_MAX_DEPTH = 3
# Дробить тайл имеет смысл ТОЛЬКО когда сервер отказал из-за тяжести запроса:
# 504/429/503 и таймаут чтения — «я не успел посчитать», четверть посчитается.
# Отказ на уровне транспорта (connection refused / network unreachable) про размер
# запроса не говорит ВООБЩЕ: хост нас не принимает, и дробление превращает один
# отказ в 4, 16, 64 повторных стука. Живой случай 15.09.2026: загрузка Москвы
# поймала блокировку overpass-api.de по IP и за три минуты выдала 58 отказов на
# 4 успеха — ровно этот механизм.
_SPLIT_WORTHY_STATUS = frozenset({429, 503, 504})
# Подряд идущие транспортные отказы = хост нас не принимает. Продолжать прогон
# бессмысленно и вредно (углубляем блокировку), поэтому после порога — стоп всего
# прогона с явной ошибкой, а не тихий пропуск категорий.
_MAX_CONSECUTIVE_TRANSPORT_ERRORS = 5
class OverpassUnreachableError(RuntimeError):
"""Overpass отказывает на уровне соединения подряд — прогон остановлен."""
class _RunState:
"""Счётчик подряд идущих транспортных отказов в рамках одного fetch_overpass."""
__slots__ = ("consecutive_transport_errors",)
def __init__(self) -> None:
self.consecutive_transport_errors = 0
def _is_overload(exc: Exception) -> bool:
"""True, если отказ говорит «запрос слишком тяжёлый» (есть смысл дробить)."""
if isinstance(exc, httpx.TimeoutException):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in _SPLIT_WORTHY_STATUS
return False
def _is_transport_error(exc: Exception) -> bool:
"""True для отказа на уровне соединения (хост не принимает), не про размер запроса."""
return isinstance(exc, httpx.TransportError) and not isinstance(exc, httpx.TimeoutException)
def _build_overpass_query(
tag_filters: tuple[tuple[str, str], ...], bbox: tuple[float, float, float, float]
) -> str:
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND)
в ОДНОМ тайле bbox (south, west, north, east) см. _bbox_tiles.
Раньше делали один большой запрос на все 14 категорий Overpass возвращал
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
быстрые запросы вместо одного 60+ секундного.
"""
south, west, north, east = EKB_BBOX
bbox = f"({south},{west},{north},{east})"
south, west, north, east = bbox
bbox_str = f"({south},{west},{north},{east})"
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
return f"[out:json][timeout:30];(node{filt}{bbox_str};way{filt}{bbox_str};);out center meta;"
def _classify(tags: dict[str, str]) -> str | None:
@ -80,55 +212,117 @@ def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
async def _fetch_category(
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
client: httpx.AsyncClient,
tag_filters: tuple[tuple[str, str], ...],
category: str,
bbox: tuple[float, float, float, float],
state: _RunState,
depth: int = 0,
) -> list[dict]:
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
"""Один per-category Overpass-запрос (для ОДНОГО тайла bbox) с ОДНИМ повтором при
транзиентной ошибке; если тайл падает оба раза адаптивно дробится на 4 четверти
(см. RECURSIVE_SPLIT_MAX_DEPTH) и перезапрашивается рекурсивно, вместо того чтобы
тихо потерять весь тайл.
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
сбоев без риска зациклиться (Overpass rate-limit max 2 concurrent, поэтому не более
2 попыток на категорию).
2 попыток на тайл ДО дробления).
"""
tag_desc = _tag_filters_desc(tag_filters)
query = _build_overpass_query(tag_filters)
query = _build_overpass_query(tag_filters, bbox)
for attempt in (1, 2):
try:
r = await client.post(OVERPASS_URL, data={"data": query})
r.raise_for_status()
elements: list[dict] = r.json().get("elements", [])
logger.info(
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
"Overpass: %s (%s) bbox=%s depth=%d%d [attempt %d]",
tag_desc,
category,
bbox,
depth,
len(elements),
attempt,
)
# Привязываем category именно к тому per-category запросу, под который
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
state.consecutive_transport_errors = 0
for el in elements:
el["_gd_category"] = category
return elements
except Exception as e:
if _is_transport_error(e):
state.consecutive_transport_errors += 1
if state.consecutive_transport_errors >= _MAX_CONSECUTIVE_TRANSPORT_ERRORS:
raise OverpassUnreachableError(
f"Overpass отказывает на уровне соединения "
f"{state.consecutive_transport_errors} раз подряд ({e}) — прогон "
f"остановлен, чтобы не стучаться в блокирующий хост"
) from e
logger.warning(
"Overpass transport error for %s bbox=%s (подряд %d) — тайл пропущен "
"без дробления: %s",
tag_desc,
bbox,
state.consecutive_transport_errors,
e,
)
return []
state.consecutive_transport_errors = 0
if attempt == 1:
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
await asyncio.sleep(3.0)
continue
if _is_overload(e) and depth < RECURSIVE_SPLIT_MAX_DEPTH:
logger.warning(
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
"Overpass failed for %s bbox=%s twice — splitting into 4 quadrants "
"(depth %d%d) instead of dropping the tile: %s",
tag_desc,
bbox,
depth,
depth + 1,
e,
)
combined: list[dict] = []
for quadrant in _split_bbox_quadrants(bbox):
combined.extend(
await _fetch_category(
client, tag_filters, category, quadrant, state, depth + 1
)
)
await asyncio.sleep(1.0)
return combined
logger.warning(
"Overpass failed for %s bbox=%s at max split depth %d — tile skipped this run: %s",
tag_desc,
bbox,
depth,
e,
)
return []
async def fetch_overpass() -> list[dict]:
"""Запросить Overpass API per category, вернуть combined список elements.
async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
"""Запросить Overpass API per category × per tile, вернуть combined список elements.
Делаем отдельные запросы вместо одного гигантского большой запрос
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
policy: max 2 concurrent, лучше 1 req/s).
policy: max 2 concurrent, лучше 1 req/s). bbox региона режется на тайлы
MAX_TILE_SIDE_DEG (_bbox_tiles) для "ekb" это ровно один тайл (без
изменения поведения), для регионов с большим bbox (напр. "msk") несколько,
чтобы не поймать 504 или тихо обрезанный ответ на плотном городе.
Overpass блокирует default `python-httpx/*` User-Agent (406) поэтому
явный UA с контактом проекта.
"""
bbox = REGION_BBOX[region]
tiles = _bbox_tiles(bbox)
state = _RunState()
headers = {
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
"Accept": "application/json",
@ -136,24 +330,34 @@ async def fetch_overpass() -> list[dict]:
all_elements: list[dict] = []
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
for tag_filters, category in OSM_CATEGORIES.items():
elements = await _fetch_category(client, tag_filters, category)
for tile in tiles:
elements = await _fetch_category(client, tag_filters, category, tile, state)
all_elements.extend(elements)
await asyncio.sleep(1.0)
logger.info(
"Overpass: total %d elements across %d category-queries",
"Overpass region=%s: total %d elements across %d category-queries × %d tiles",
region,
len(all_elements),
len(OSM_CATEGORIES),
len(tiles),
)
return all_elements
def sync_poi_to_db() -> dict[str, int]:
"""Синхронизирует POI из Overpass в osm_poi_ekb.
def sync_poi_to_db(region: str = DEFAULT_REGION) -> dict[str, int]:
"""Синхронизирует POI из Overpass в osm_poi_ekb для одного региона.
region ключ REGION_BBOX ("ekb" по умолчанию, сохраняет старое поведение
weekly-расписания). Имя таблицы osm_poi_ekb историческое (изначально
ЕКБ-only); таблица читается ещё в двух местах вне Site Finder (FDW-таблица
gendesign_osm_poi_ekb + локальное зеркало osm_poi_ekb_local в tradein), поэтому
НЕ переименована: переименование потянуло бы миграции в обеих половинах
монорепо (FDW-объект + зеркало + их индексы) ради косметики.
UPSERT по UNIQUE(osm_type, osm_id, category).
Returns: counters {fetched, inserted, updated, skipped_old}.
"""
elements = asyncio.run(fetch_overpass())
elements = asyncio.run(fetch_overpass(region))
# 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
two_years_ago = date.today() - timedelta(days=730)

View file

@ -329,7 +329,7 @@ def build_beat_schedule() -> dict:
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
# territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat
# territorial_zones в nspd_quarter_dumps (nspd_sync, layer 875838). Активный beat
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
#
@ -346,11 +346,13 @@ def build_beat_schedule() -> dict:
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
# за ~5 недель полного обновления.
#
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
# cooldown 24-48h (проверить через targeted test).
# DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась
# догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды
# 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки
# spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это
# блокировка, ожидание её не снимает. Каталог не собирался с 19.05.
# Включать только после решения по прокси и принятого по числу строк
# kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py.
# schedule["scrape-kn-catalog-objects-weekly"] = {
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
@ -362,11 +364,11 @@ def build_beat_schedule() -> dict:
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
#
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
# DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP
# Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру.
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 МСК.
# schedule["scrape-kn-catalog-flats-weekly"] = {

View file

@ -110,6 +110,25 @@ _UPSERT_NO_ACT_SQL = text(
)
# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982).
#
# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая
# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке
# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон
# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие
# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит
# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же
# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается.
_DELETE_STALE_PARSE_SQL = text(
"""
DELETE FROM land_reservation
WHERE cad_num = CAST(:cad_num AS text)
AND doc_url = CAST(:doc_url AS text)
AND act_number IS DISTINCT FROM CAST(:act_number AS text)
"""
)
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
count = 0
@ -117,6 +136,7 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
try:
with db.begin_nested(): # SAVEPOINT per-row
db.execute(_DELETE_STALE_PARSE_SQL, row)
db.execute(upsert_sql, row)
count += 1
except Exception as exc:

View file

@ -24,8 +24,9 @@ Sequencing dependency (важно): эта задача НЕ дублирует
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) WAF-cooldown на
VPS IP; включается вручную после проверки targeted-тестом.
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за
StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает;
включать после прокси и принятого kn-прогона (#3307).
"""
from __future__ import annotations
@ -116,7 +117,7 @@ def scrape_kn_catalog_flats(
Concurrency:
No Redis lock consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
Beat отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне
маловероятно. Если случится:
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.

View file

@ -9,7 +9,7 @@ Selector logic:
- DATE(catalog_scraped_at) = CURRENT_DATE уже сегодня обновлён, пропускаем
- force=True игнорирует фильтр, загружает все объекты последнего snapshot
Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443).
"""
from __future__ import annotations

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

@ -1,10 +1,11 @@
"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
smoke-тест/re-trigger не углубил бан.
/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule
для обоих тасков отключён по этой причине эти эндпоинты должны отказывать в
запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа то, по чему оператор
решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не
ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего.
"""
from __future__ import annotations
@ -19,6 +20,15 @@ OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
def _assert_refusal_names_real_condition(detail: str) -> None:
assert "2443" in detail
assert "i_understand_waf_risk" in detail
# Условие снятия — прокси и принятый kn-прогон (#3307). Обещание «cooldown
# прошёл» подсказывало оператору обойти блокировку, которую ожидание не снимает.
assert "3307" in detail
assert "cooldown" not in detail.lower()
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
result = MagicMock()
result.id = task_id
@ -33,9 +43,7 @@ def test_kn_catalog_objects_refuses_without_override() -> None:
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
_assert_refusal_names_real_condition(resp.json()["detail"])
mock_apply_async.assert_not_called()
@ -68,9 +76,7 @@ def test_kn_catalog_flats_refuses_without_override() -> None:
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
_assert_refusal_names_real_condition(resp.json()["detail"])
mock_apply_async.assert_not_called()

View file

@ -264,8 +264,10 @@ def test_exclude_competitor_obj_ids_filter() -> None:
# После исключения obj_id=20 список пуст → пустой ответ
assert body["top_layouts"] == []
assert body["data_quality"]["confidence"] == "low"
# objects_total_in_radius = 1 (до исключения)
assert body["data_quality"]["objects_total_in_radius"] == 1
# objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464);
# число obj_id в радиусе до исключения — raw_objects_total
assert body["data_quality"]["objects_total_in_radius"] == 0
assert body["data_quality"]["raw_objects_total"] == 1
finally:
app.dependency_overrides.clear()

View file

@ -1,4 +1,4 @@
"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078).
"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164).
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал и алерты
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
@ -16,9 +16,7 @@
реально ушло бы в сеть.
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
бы с оригиналом на первой же правке. Сорсить файл целиком нельзя у
`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы
настоящие сетевые проверки.
бы с оригиналом на первой же правке.
"""
from __future__ import annotations
@ -34,7 +32,6 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parents[3]
SENDERS = {
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
"uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh",
}
FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n'
@ -105,7 +102,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str]
f'ARGS_DUMP="{_posix(dump)}"',
'TELEGRAM_BOT_TOKEN="123:FAKE"',
'TELEGRAM_CHAT_ID="-1004443088679"',
f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"",
f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"',
]
if topic is not None:
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
@ -153,3 +150,73 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None:
args = _run_notify(tmp_path, SENDERS[name], topic="158")
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245),
# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт
# только в env-файле на хосте, поэтому образец — единственное место в репозитории,
# откуда следующий, кто заводит хост, узнает правильный адрес.
INFRA_TOPIC = "245"
BACKUP_ENV_EXAMPLES = [
"gendesign-backup.default.example",
"gendesign-backup-couchdb.default.example",
"gendesign-backup-forgejo.default.example",
]
@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES)
def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None:
"""Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо.
Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим
маркером, а env-файл образец, заполненный так, как он велит человеку:
раскомментированы строки `TELEGRAM_*`, подставлены токен и чат.
Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат.
`notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в
окружении, тема из другого файла молча не подхватится.
"""
text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8")
filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text)
filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled)
filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled)
env_file = tmp_path / "backup.env"
env_file.write_text(filled, encoding="utf-8", newline="\n")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake = bin_dir / "curl"
fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n")
fake.chmod(0o755)
dump = tmp_path / "args.txt"
# Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе
# перебили бы файл, и тест проверял бы не образец.
prefix = [
f'PATH="{_posix(bin_dir)}:$PATH"',
f'ARGS_DUMP="{_posix(dump)}"',
'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""',
f'BACKUP_ENV_FILE="{_posix(env_file)}"',
f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"',
]
script = REPO_ROOT / "ops" / "check-backup-staleness.sh"
proc = subprocess.run(
[
"bash",
"-c",
" ".join(prefix)
+ f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"',
],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
env=dict(os.environ),
)
args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else []
assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}"
threads = [a for a in args if a.startswith("message_thread_id=")]
assert threads == [f"message_thread_id={INFRA_TOPIC}"], (
f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, "
f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})"
)

View file

@ -14,7 +14,9 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15
Как это ломало алерты:
* `ops/uptime-healthcheck.sh` ОДИН `curl`, дальше `|| log WARN`. Каждый
* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был
запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) ОДИН
`curl`, дальше `|| log WARN`. Каждый
отказ терял уведомление целиком. Watchdog, который не может дозваться,
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
@ -51,7 +53,6 @@ import pytest
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
UPTIME = "ops/uptime-healthcheck.sh"
LIB_BACKUP = "ops/lib-backup.sh"
# См. подробное обоснование shutil.which в
@ -65,8 +66,7 @@ if BASH is None: # pragma: no cover - окружение без bash не за
def _extract_function(script_rel: str, name: str = "notify") -> str:
"""Достаёт тело одной функции из скрипта — не весь файл.
Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально
ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh.
Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh.
Сопоставление точное (`name() {`), иначе `notify` поймал бы
`notify_fallback_mail` соседнюю функцию в том же файле.
@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]:
return calls, out, err
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
"""Один отказ — алерт всё равно доставляется со второй попытки.
@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным"
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
def test_gives_up_after_three_attempts(script_rel: str) -> None:
"""Повторы ограничены: три попытки, а не бесконечный цикл.
@ -164,12 +164,6 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None:
assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз"
def test_uptime_reports_undelivered_alert_loudly() -> None:
"""Когда все три попытки провалились — это видно в логе, а не молча."""
_calls, _out, err = _run_notify(UPTIME, fail_times=99)
assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}"
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
"""Транзиентный таймаут не должен трогать запасной канал.

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,176 @@
"""Правки 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. `POST /-/reload` лечит правила они подключены
КАТАЛОГОМ. prometheus.yml же бинд-маунт ОДНОГО файла: reload перечитывает
за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён,
reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении
инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его
поведение test_metrics_single_file_mounts.py), а promtool проверяет файлы С
ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод.
Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска,
(2) reload и пересоздание ТОЛЬКО после успешной валидации, (3) шаг обязан
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы
Grafana перечитываются, и отказ этого шага роняет деплой.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml"
def _text() -> str:
return WORKFLOW.read_text(encoding="utf-8")
def _guard_start(text: str) -> int:
"""Начало `if <проверка promtool>; then` — ближайший `if` перед check config."""
guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG))
assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён"
return guard
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 = _guard_start(text)
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 = _guard_start(text)
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 не изменился, шаг обязан падать, а не считаться успешным"
)
def test_promtool_checks_files_from_disk_not_the_running_container() -> None:
"""Проверяется то, что сейчас применится, а не то, что держит контейнер.
prometheus.yml смонтирован одним файлом: `docker exec promtool check config`
в работающем контейнере читает СТАРЫЙ инод битая правка прошла бы проверку
и уехала бы в пересоздание.
"""
text = _text()
guard = text[_guard_start(text) : text.index(CHECK_CONFIG)]
assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером"
assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, (
"одноразовый promtool смотрит не на файлы с диска по боевым путям"
)
assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), (
"promtool всё ещё проверяет работающий контейнер — то есть старый инод"
)
def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None:
"""Пересоздание по иноду — после проверки promtool и до reload."""
text = _text()
check = text.index("promtool check config /etc/prometheus/prometheus.yml")
recreate = re.search(
r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+"
r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml",
text,
)
assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет"
else_pos = text.index("else", _guard_start(text))
assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, (
"пересоздание Prometheus стоит не между успешной проверкой и reload"
)
def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None:
"""Датасорсы Grafana применяются только при старте — их надо перечитать явно.
Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с,
POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в
v11.5.1 «Datasources config reloaded» (pkg/api/admin_provisioning.go).
"""
text = _text()
call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload")
assert call != -1, "деплой не перечитывает датасорсы Grafana"
block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S)
assert block, "результат перезагрузки датасорсов не разбирается"
ok, _, failed = block.group(1).partition(";;")
assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки"
assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой"

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

@ -0,0 +1,310 @@
"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера.
ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса),
перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён,
POST /-/reload rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний
job. Деплой зелёный, конфиг старый у отказа нет отрицательного признака (#3467).
Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх
postgres-экспортёров (#3486).
ЧТО ПРОВЕРЯЕТСЯ.
1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим
на совершённые действия (пересоздал / не тронул / упал), а не на текст.
2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в
той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose:
добавят новый тест покраснеет, пока путь не появится.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh"
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
SRC = "ops/metrics/loki/loki-config.yml"
DST = "/etc/loki/loki-config.yml"
# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG.
# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен);
# код из $FAKE_PS_RC;
# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит
# инод $FAKE_NEW_VIEW (пусто — не видит ничего);
# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID).
FAKE_DOCKER = r"""#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
cmd="$1"; shift
case "$cmd" in
exec)
view="$FAKE_VIEW/$1"
[ -f "$view" ] || exit 1
cat "$view"
;;
compose)
case " $* " in
*" ps "*)
[ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; }
cat "$FAKE_CID_FILE"
;;
*" up "*)
printf 'cid-new\n' > "$FAKE_CID_FILE"
[ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new"
;;
esac
;;
esac
exit 0
"""
# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`).
# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально.
STAT_SHIM = (
'#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n'
'exec /usr/bin/stat "$@"\n'
)
NO_SLEEP = "#!/bin/sh\nexit 0\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)
def _gnu_stat() -> bool:
return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0
@pytest.fixture
def host(tmp_path: Path) -> Path:
"""Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод."""
tree = tmp_path / "opt" / "gendesign"
(tree / "ops" / "metrics" / "loki").mkdir(parents=True)
shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name)
(tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_write_exec(bin_dir / "docker", FAKE_DOCKER)
_write_exec(bin_dir / "sleep", NO_SLEEP)
if not _gnu_stat():
_write_exec(bin_dir / "stat", STAT_SHIM)
view = tmp_path / "view"
view.mkdir()
(view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8")
(tmp_path / "cid").write_text("cid-old\n", encoding="utf-8")
(tmp_path / "log").write_text("", encoding="utf-8")
return tree
def _ino(tree: Path) -> int:
return os.stat(tree / SRC).st_ino
def _stale(tree: Path) -> None:
"""Контейнер держит прежний инод — как после `git reset --hard`."""
(tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8")
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_VIEW": str(root / "view"),
**env_extra,
}
proc = subprocess.run(
[
"sh",
str(tree / "ops" / "metrics" / SCRIPT.name),
"gendesign-metrics",
"docker-compose.metrics.yml",
"loki",
SRC,
DST,
],
cwd="/", # скрипт обязан сам встать в корень дерева
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 _recreates(calls: list[str]) -> list[str]:
return [c for c in calls if "--force-recreate" in c]
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
def test_fresh_inode_touches_nothing(host: Path) -> None:
"""Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря."""
rc, out, calls = _run(host)
assert rc == 0, out
assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}"
def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None:
"""Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало."""
_stale(host)
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
assert rc == 0, out
recreates = _recreates(calls)
assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}"
assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}"
assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}"
assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0]
assert any(c.startswith("exec cid-new ") for c in calls), (
f"после пересоздания инод НОВОГО контейнера не сверен: {calls}"
)
def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None:
"""Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом."""
_stale(host)
rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1))
assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}"
assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}"
def test_unreadable_container_view_counts_as_stale(host: Path) -> None:
"""Инод в контейнере не прочитался — это расхождение, а не «доехало»."""
(host.parent.parent / "view" / "cid-old").unlink()
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
assert rc == 0, out
assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}"
def test_not_running_container_is_left_alone(host: Path) -> None:
"""Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя."""
(host.parent.parent / "cid").write_text("", encoding="utf-8")
rc, out, calls = _run(host)
assert rc == 0, out
assert not _recreates(calls), f"поднят незапущенный сервис: {calls}"
def test_compose_failure_is_not_read_as_not_running(host: Path) -> None:
"""Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую."""
_stale(host)
rc, out, calls = _run(host, FAKE_PS_RC="1")
assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}"
assert not _recreates(calls), calls
def test_missing_host_file_is_not_success(host: Path) -> None:
"""Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя."""
(host / SRC).unlink()
rc, out, _calls = _run(host)
assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}"
# ── Гейт: у каждого пофайлового маунта есть путь доезда ──────────────────────
# Производные файлы: их рендерит сам деплой, в git их нет.
# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED;
# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется.
RENDERED = {
"ops/metrics/alertmanager/alertmanager.yml",
"ops/metrics/prometheus/alertmanager_targets.gen.yml",
}
# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл,
# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy).
MOUNT = re.compile(r"^\./(?P<src>(?:\$\{[^}]*\}|[^:])+):(?P<dst>/[^:]+)")
COMPOSE_FILES = {
"docker-compose.metrics.yml": "gendesign-metrics",
"docker-compose.metrics-agent.yml": "gendesign-metrics-agent",
}
def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]:
"""Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю."""
if compose_name == "docker-compose.metrics.yml":
return ["server"]
return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"]
def _commands(job: str) -> str:
"""ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены.
Комментарии потому что разбор дефекта содержит его формулировку дословно.
"""
spec = yaml.safe_load(WORKFLOW.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} не ровно один ssh-шаг — гейт ослеп"
kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")]
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]:
spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8"))
found = []
for service, body in spec["services"].items():
for vol in body.get("volumes") or []:
m = MOUNT.match(vol) if isinstance(vol, str) else None
if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED:
continue
found.append((service, m["src"], m["dst"], body.get("profiles") or []))
return found
def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str):
script_call = re.search(
rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}"
rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*",
commands,
)
forced = re.search(
rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands
)
return script_call or forced
@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES))
def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None:
project = COMPOSE_FILES[compose_name]
mounts = _file_mounts(compose_name)
assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла"
missing = []
for service, src, dst, profiles in mounts:
for job in _jobs_for(compose_name, profiles):
hit = _arrival(_commands(job), project, compose_name, service, src, dst)
if not hit:
missing.append(f"{job}: {service} {src}{dst}")
elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)):
missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}")
assert not missing, (
"пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер "
"останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing)
)
def test_gate_sees_the_known_mounts() -> None:
"""Проверка на себя: маска обязана находить маунты, ради которых гейт заведён."""
server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")}
assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server
assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server
agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")}
for exporter in ("gendesign", "tradein", "infra"):
assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent
def test_gate_would_notice_a_mount_without_arrival() -> None:
"""Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению."""
commands = _commands("server")
assert not _arrival(
commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"

View file

@ -1,198 +0,0 @@
"""Тесты для _save_territorial_zones (bulk_harvest.py) — mock-based.
Проверяет:
- Успешный UPSERT 3 features 3 строки вставлены
- Повторный вызов ON CONFLICT обновляет, не дублирует
- Feature без geometry строка вставлена с geom=NULL, без краша
- Feature без zone_id синтетический fallback zone_id используется
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from app.services.cadastre.bulk_harvest import _save_territorial_zones
def _make_feature(
feature_id: Any = "zone_1",
zone_code: str = "Ж-1",
zone_name: str = "Жилая смешанная",
permitted_use: str = "ИЖС",
has_geometry: bool = True,
) -> dict:
"""Создать raw feature dict в формате get_features_in_bbox_grid."""
geom = (
{
"type": "Polygon",
"coordinates": [
[
[6090000.0, 7590000.0],
[6090100.0, 7590000.0],
[6090100.0, 7590100.0],
[6090000.0, 7590100.0],
[6090000.0, 7590000.0],
]
],
}
if has_geometry
else None
)
return {
"id": feature_id,
"geometry": geom,
"properties": {
"zone_code": zone_code,
"zone_name": zone_name,
"permitted_use": permitted_use,
},
}
def _make_db_mock() -> MagicMock:
"""Mock SQLAlchemy Session с begin_nested() savepoint support."""
db = MagicMock()
# begin_nested() используется как context manager
savepoint_ctx = MagicMock()
savepoint_ctx.__enter__ = MagicMock(return_value=savepoint_ctx)
savepoint_ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = savepoint_ctx
return db
class TestSaveTerritorialZones:
"""Тесты для _save_territorial_zones."""
def test_three_features_inserted(self) -> None:
"""3 features → returned count == 3, execute вызван 3 раза."""
db = _make_db_mock()
features = [
_make_feature("z1", "Ж-1"),
_make_feature("z2", "ОД-1"),
_make_feature("z3", "П-1"),
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 3
assert db.execute.call_count == 3
db.commit.assert_called_once()
def test_empty_features_list(self) -> None:
"""Пустой список → 0 inserted, commit всё равно вызван."""
db = _make_db_mock()
result = _save_territorial_zones(db, "66:41:0204016", [])
assert result == 0
db.execute.assert_not_called()
db.commit.assert_called_once()
def test_feature_without_geometry_no_crash(self) -> None:
"""Feature без geometry → geom=NULL, строка вставлена без краша."""
db = _make_db_mock()
features = [_make_feature("zone_no_geom", has_geometry=False)]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
# Проверяем что geom параметр передан как None
call_kwargs: dict = db.execute.call_args[0][1]
assert call_kwargs["geom"] is None
def test_feature_without_zone_id_uses_fallback(self) -> None:
"""Feature без id → md5-based fallback zone_id (stable между runs)."""
db = _make_db_mock()
features = [
{
"id": None,
"geometry": None,
"properties": {"zone_code": "Ж-2"},
}
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
call_kwargs = db.execute.call_args[0][1]
zone_id: str = call_kwargs["zone_id"]
# fallback zone_id содержит quarter_cad и стабильный hash (12 hex chars)
assert zone_id.startswith("66:41:0204016_")
suffix = zone_id.split("_", 3)[-1]
assert len(suffix) == 12
assert all(c in "0123456789abcdef" for c in suffix)
# Второй вызов с теми же данными → тот же zone_id (идемпотентность)
db2 = _make_db_mock()
_save_territorial_zones(db2, "66:41:0204016", features)
call_kwargs2 = db2.execute.call_args[0][1]
assert call_kwargs2["zone_id"] == zone_id
def test_zone_id_from_props_id(self) -> None:
"""Если feature.id=None, но props['id'] есть — используется props['id']."""
db = _make_db_mock()
features = [
{
"id": None,
"geometry": None,
"properties": {"id": "props_id_42", "zone_code": "Ж-3"},
}
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["zone_id"] == "props_id_42"
def test_execute_error_logged_not_raised(self) -> None:
"""Exception в execute → строка не вставлена, warning залогирован, не re-raise."""
db = _make_db_mock()
db.execute.side_effect = RuntimeError("DB error")
features = [_make_feature("z_err")]
# Не должен бросить исключение
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 0
db.commit.assert_called_once()
def test_savepoint_used_per_row(self) -> None:
"""begin_nested() вызывается для каждой строки (SAVEPOINT паттерн)."""
db = _make_db_mock()
features = [_make_feature(f"z{i}") for i in range(3)]
_save_territorial_zones(db, "66:41:0204016", features)
assert db.begin_nested.call_count == 3
def test_quarter_cad_param_passed(self) -> None:
"""quarter_cad правильно передаётся в SQL параметры."""
db = _make_db_mock()
features = [_make_feature("zone_check")]
_save_territorial_zones(db, "66:41:9999999", features)
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["quarter_cad"] == "66:41:9999999"
def test_raw_props_serialized(self) -> None:
"""raw_props — JSON строка из properties dict."""
import json
db = _make_db_mock()
features = [
{
"id": "z_props",
"geometry": None,
"properties": {"zone_code": "ОД-2", "extra": "value"},
}
]
_save_territorial_zones(db, "66:41:0204016", features)
call_kwargs = db.execute.call_args[0][1]
raw = json.loads(call_kwargs["raw_props"])
assert raw["zone_code"] == "ОД-2"
assert raw["extra"] == "value"

View file

@ -0,0 +1,103 @@
"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982).
Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У
постановлений Администрации города его нет, поэтому на проде `act_number` пуст у
всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0).
Фрагменты дословный OCR пяти прод-документов из test_2464_act_date_citation
(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера из самих PDF:
1413, 259, 2687, 863, 1504. Помехи в тех же текстах «Решение Думы 60/1»
(Генплан) и «Приказ Министерства 746-П»: их номер браться не должен.
Тест зовёт `extract_izyatie_records` путь загрузчика и краснеет на origin/main
неверным значением (None вместо номера), а не ошибкой сигнатуры.
"""
from __future__ import annotations
import pytest
from app.services.scrapers.izyatie_ocr import extract_izyatie_records
from app.services.scrapers.page_reservation_parser import extract_reservations
from tests.services.scrapers.test_2464_act_date_citation import (
ДОКЕРЕМЕШАННЫЙ,
ДОКЕРЕМЕШАННЫЙ_2,
ДОКРЯМОЙ,
ДОК_РАЗВЯЗКА,
ДОКНЕРГОСТРОИТЕЛЕЙ,
ЗАГОЛОВОК,
КАД,
)
ПЯТЬОКУМЕНТОВ = [
pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"),
pytest.param(ДОКНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"),
pytest.param(ДОКЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"),
pytest.param(ДОКЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"),
pytest.param(ДОКРЯМОЙ, "1504", "2019-06-24", id="татищева"),
]
def апись(текст: str) -> dict:
записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
return записи[0]
@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬОКУМЕНТОВ)
def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None:
"""Головной: номер постановления без суффикса извлекается, и это номер того же акта,
чья дата стоит в act_date, а не Решения Думы или приказа Министерства."""
запись = апись(текст)
assert запись["act_number"] == номер
assert запись["act_date"] == дата
@pytest.mark.parametrize(("текст", "номер", "ата"), ПЯТЬОКУМЕНТОВ)
def test_page_parser_takes_the_same_numbers(текст: str, номер: str, ата: str) -> None:
"""Второе место того же дефекта — page_reservation_parser (pravo.gov66).
Там номер первое «» в тексте. В двух документах первыми идут « 60/1» и
« 746-П»: их нельзя ни взять, ни обрезать до «60»/«746».
"""
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер
def test_citations_alone_give_no_number() -> None:
"""Контроль: без постановления — None, а не номер Генплана или приказа."""
текст = (
"Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n"
"Приказ Министерства строительства и развития инфраструктуры Свердловской "
"области от 30.12.2021 № 746-П"
)
assert апись(текст)["act_number"] is None
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None
def test_number_belongs_to_the_act_whose_date_is_taken() -> None:
"""Контроль связки: номер берётся у того же акта, что и дата, а не первое «№».
Тот же документ «развязка», но у приказа Министерства номер без суффикса.
Первое подходящее «» в тексте «746», дата при этом у постановления 1413:
в строке оказалась бы пара реквизитов от двух разных документов.
"""
текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746")
запись = апись(текст)
assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413")
def test_page_parser_prefers_own_number_over_cited_regional_act() -> None:
"""Контроль порядка: собственный номер в шапке без суффикса не пропускается ради
следующего « -ПП» номера цитируемого постановления Правительства."""
текст = (
"ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n"
"В соответствии с постановлением Правительства Свердловской области "
f"от 26.02.2020 № 100-ПП\n{КАД}"
)
assert extract_reservations(текст)[0].act_number == "1234"
def test_regional_suffix_still_extracted() -> None:
"""Контроль от переусердствования: областной номер с суффиксом не потерян."""
assert апись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП"
assert апись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП"

View file

@ -0,0 +1,246 @@
"""Gap-fill конкурентов берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962).
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
не трогает complex_id. Прод 17.09.2026: из 303 677 строк с complex_id у 236 354
проект чужой; у 185 gap-fill конкурентов своих лотов 23 %, скорость завышена в
медиане в 39 раз.
Тест герметичный и прогоняет НАСТОЯЩИЕ `_COMPETITORS_SQL` и
`_OBJECTIVE_PRICE_FALLBACK_SQL`: временные таблицы затеняют боевые в пределах сессии.
Нужен Postgres с PostGIS (ST_DWithin по geography). В CI он есть
(.forgejo/workflows/ci.yml, образ postgis/postgis), и там тест не пропускается
никогда: без PostGIS он падает с настоящей причиной. Пропуск разрешён только вне CI,
на машине без базы, и объявлен в skip_allowlist.txt.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _postgis_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
return True, ""
return False, "нет расширения postgis"
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _postgis_reachable()
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
pytestmark = pytest.mark.skipif(
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
)
_SCHEMA = [
"""CREATE TEMP TABLE domrf_kn_objects (
obj_id bigint, comm_name text, dev_name text, obj_class text,
obj_class_fallback text, latitude double precision, longitude double precision,
flat_count int, site_status text, snapshot_date date) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_complex_mapping (
domrf_obj_id bigint, objective_complex_name text) ON COMMIT DROP""",
"""CREATE TEMP TABLE complexes (
id bigint, canonical_name text, latitude double precision,
longitude double precision) ON COMMIT DROP""",
"""CREATE TEMP TABLE complex_sources (
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_lots (
project_name text, complex_id bigint, price_per_m2_rub numeric) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_corpus_room_month (
project_name text, report_month date, deals_total_count int) ON COMMIT DROP""",
]
MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8
_DATA = [
# obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга
"""INSERT INTO domrf_kn_objects VALUES
(1, 'ЖК Мичуринский', NULL, NULL, NULL, 56.840, 60.600, 500, 'Строящиеся', CURRENT_DATE),
(2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE),
(3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500,
'Строящиеся', CURRENT_DATE),
(4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE),
(5, 'Квартал "Татлин"', NULL, NULL, NULL, 56.835, 60.610, 500, 'Строящиеся',
CURRENT_DATE),
(6, 'Парковый', NULL, NULL, NULL, 56.855, 60.590, 500, 'Строящиеся', CURRENT_DATE),
(7, 'ЖК Роща Парк', NULL, NULL, NULL, 56.825, 60.630, 500, 'Строящиеся', CURRENT_DATE),
(8, 'ЖК Сосны', NULL, NULL, NULL, 56.860, 60.650, 500, 'Строящиеся', CURRENT_DATE)""",
"INSERT INTO objective_complex_mapping VALUES (4, 'Явный')",
"""INSERT INTO complexes VALUES
(10, 'ЖК Мичуринский', 56.840, 60.600),
(20, 'ЖК VEER PARK', 56.850, 60.620),
(30, 'СтудияПарк', 56.830, 60.580),
(50, 'Квартал "Татлин"', 56.835, 60.610),
(60, 'Парковый', 56.855, 60.590),
(70, 'Роща', 56.825, 60.630),
(71, 'Роща Парк', 56.8259, 60.630),
(80, 'ЖК Сосны', 56.860, 60.650)""",
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
# 70 → 'Роща': проект без лотов, complex ближе «Роща Парк» (71, в 100 м).
# 80 → два objective-проекта: UNIQUE(source, source_id) этого не запрещает.
"""INSERT INTO complex_sources VALUES
(10, 'objective', 'Мичуринский'),
(20, 'objective', 'Clever Park'),
(30, 'objective', 'Студия Парк'),
(50, 'objective', 'Квартал Татлин'),
(60, 'objective', 'Парковый квартал'),
(70, 'objective', 'Роща'),
(71, 'objective', 'Роща Парк'),
(80, 'objective', 'Клён'),
(80, 'objective', 'Сосны')""",
# Под complex_id=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим
# complex_id; два своих лота новые — complex_id у них NULL.
"""INSERT INTO objective_lots VALUES
('Мичуринский', 10, 100000), ('Мичуринский', NULL, 110000),
('Мичуринский', NULL, 120000),
('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000),
('Clever Park', NULL, 150000),
('Студия Парк', NULL, 90000),
('Явный', NULL, 200000),
('Квартал Татлин', NULL, 95000),
('Парковый квартал', NULL, 130000),
('Роща Парк', NULL, 105000),
('Клён', NULL, 250000),
('Сосны', NULL, 115000)""",
"""INSERT INTO objective_corpus_room_month VALUES
('Мичуринский', date_trunc('month', now())::date, 30),
('Малахит', date_trunc('month', now())::date, 300),
('Clever Park', date_trunc('month', now())::date, 90),
('Студия Парк', date_trunc('month', now())::date, 15),
('Явный', date_trunc('month', now())::date, 60),
('Квартал Татлин', date_trunc('month', now())::date, 18),
('Парковый квартал', date_trunc('month', now())::date, 12),
('Роща Парк', date_trunc('month', now())::date, 21),
('Клён', date_trunc('month', now())::date, 240),
('Сосны', date_trunc('month', now())::date, 27)""",
]
@pytest.fixture(scope="module")
def result() -> tuple[dict[int, float], dict[int, float]]:
"""(velocity по obj_id, медианная цена objective-fallback по obj_id)."""
from app.services.site_finder.competitors import (
_COMPETITORS_SQL,
_OBJECTIVE_PRICE_FALLBACK_SQL,
_VELOCITY_MATCH_RADIUS_M,
)
session = sessionmaker(bind=create_engine(_dsn()))()
try:
for stmt in _SCHEMA + _DATA:
session.execute(text(stmt))
vel_rows = session.execute(
_COMPETITORS_SQL,
{
"center_lon": 60.6,
"center_lat": 56.84,
"radius_m": 5000.0,
"time_window_months": 3.0,
"window_interval": "3 months",
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
).mappings()
velocity = {int(r["obj_id"]): float(r["velocity_per_month"]) for r in vel_rows}
price_rows = session.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES],
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
).mappings()
price = {int(r["obj_id"]): float(r["median_price_per_m2"]) for r in price_rows}
return velocity, price
finally:
session.rollback()
session.close()
def test_gapfill_velocity_counts_only_own_project(result) -> None:
"""30 сделок своего ЖК за 3 месяца = 10/мес; с чужим «Малахитом» было бы 110."""
velocity, _ = result
assert velocity[MICH] == pytest.approx(10.0), velocity
def test_gapfill_price_is_median_of_own_lots(result) -> None:
"""Медиана своих лотов 100/110/120 тыс. = 110 тыс.; по complex_id было бы 300 тыс."""
_, price = result
assert price[MICH] == pytest.approx(110000.0), price
def test_wrong_fuzzy_link_gives_no_numbers(result) -> None:
"""«ЖК VEER PARK» связан в complex_sources с 'Clever Park' — чужие цифры не берём."""
velocity, price = result
assert velocity[VEER] == 0.0, velocity
assert VEER not in price, price
def test_space_difference_still_matches(result) -> None:
"""«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс."""
velocity, price = result
assert velocity[STUDIO] == pytest.approx(5.0), velocity
assert price[STUDIO] == pytest.approx(90000.0), price
def test_explicit_mapping_unchanged(result) -> None:
"""Явный маппинг правка не трогает: 60 / 3 = 20, цена 200 тыс."""
velocity, price = result
assert velocity[EXPLICIT] == pytest.approx(20.0), velocity
assert price[EXPLICIT] == pytest.approx(200000.0), price
def test_quotes_inside_name_still_match(result) -> None:
"""«Квартал "Татлин"» и «Квартал Татлин»: кавычки посреди имени сверку не ломают.
Убрать только пробелы мало: «квартал"татлин"» и «кварталтатлин» не входят друг в
друга. 18 сделок / 3 = 6, цена 95 тыс.
"""
velocity, price = result
assert velocity[TATLIN] == pytest.approx(6.0), velocity
assert price[TATLIN] == pytest.approx(95000.0), price
def test_project_name_longer_than_object_name_matches(result) -> None:
"""Объект «Парковый», проект «Парковый квартал»: имя сверяется в обе стороны. 12 / 3 = 4."""
velocity, price = result
assert velocity[PARK] == pytest.approx(4.0), velocity
assert price[PARK] == pytest.approx(130000.0), price
def test_nearest_complex_without_lots_does_not_eat_the_match(result) -> None:
"""#968: ближайший complex «Роща» без лотов не забирает объект у «Роща Парк» в 100 м.
Иначе у «ЖК Роща Парк» не было бы ни скорости, ни цены. 21 / 3 = 7, цена 105 тыс.
"""
velocity, price = result
assert velocity[GROVE] == pytest.approx(7.0), velocity
assert price[GROVE] == pytest.approx(105000.0), price
def test_complex_with_two_projects_takes_the_matching_one(result) -> None:
"""У complex два objective-проекта, «Клён» и «Сосны»: берём сверенный по имени.
Без этого DISTINCT ON брал любой из двух (здесь «Клён»), сверка его отвергала,
и «Сосны» терялись. 27 / 3 = 9, цена 115 тыс.
"""
velocity, price = result
assert velocity[PINES] == pytest.approx(9.0), velocity
assert price[PINES] == pytest.approx(115000.0), price

View file

@ -433,7 +433,43 @@ def test_exclude_competitor_obj_ids() -> None:
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts == []
assert resp.data_quality.objects_total_in_radius == 1
# после исключения комплексов 0 (знаменатель coverage), в радиусе было 1 obj_id
assert resp.data_quality.objects_total_in_radius == 0
assert resp.data_quality.raw_objects_total == 1
@pytest.mark.parametrize(
("filter_ids", "vel_obj_ids", "expected_total", "expected_with_data"),
[
([99], None, 0, 0), # фильтр не оставил никого → ранний пустой ответ
([20], None, 1, 0), # остался 1 комплекс без velocity → пустой ответ
([20], [20], 1, 1), # остался 1 комплекс с velocity → штатная ветка
],
ids=["all-filtered", "no-velocity", "normal"],
)
def test_objects_total_is_coverage_denominator_in_every_branch(
filter_ids: list[int],
vel_obj_ids: list[int] | None,
expected_total: int,
expected_with_data: int,
) -> None:
"""#2464 (best_layouts:1113): objects_total_in_radius значит одно во всех ветках.
UI и PDF печатают «покрытие P% (Y из N комплексов)», значит N обязан быть
знаменателем P, а P считается по комплексам ПОСЛЕ filter. Раньше ранний пустой
ответ отдавал число ДО фильтра (2), две другие ветки после.
"""
id_rows = [_obj_id_row(20), _obj_id_row(30)]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=vel_obj_ids)] if vel_obj_ids else []
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
resp = get_best_layouts(db, CAD_NUM, _request(filter_competitor_obj_ids=filter_ids))
dq = resp.data_quality
assert dq.objects_total_in_radius == expected_total
assert dq.objects_with_velocity_data == expected_with_data
assert dq.raw_objects_total == 2 # число до фильтра — отдельным полем
expected_pct = expected_with_data / expected_total * 100.0 if expected_total else 0.0
assert dq.velocity_coverage_pct == pytest.approx(expected_pct)
# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────

View file

@ -489,7 +489,7 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non
db = MagicMock()
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы корректно пропускаются (тест про
# grid-walk фаза корректно пропускается (тест про
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
# db.execute().mappings().first() в этом тесте.
db.execute = MagicMock(
@ -554,7 +554,7 @@ async def test_harvest_quarter_calls_upsert_features() -> None:
db = MagicMock()
# phase_state = None → начинаем с нуля
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -759,7 +759,7 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero()
db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -834,7 +834,7 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -904,7 +904,7 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N
db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -1223,7 +1223,7 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -1518,7 +1518,6 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
db = _mock_db_grid_bbox()
client = AsyncMock()
client.search_by_quarter = AsyncMock(return_value=snapshot)
client.get_territorial_zones_in_bbox = AsyncMock(return_value=[])
progress_states: list[dict[str, Any]] = []
@ -1557,6 +1556,42 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
assert progress_states[-1]["phase"] == "done"
@pytest.mark.asyncio
async def test_harvest_quarter_makes_no_territorial_zones_request_2985() -> None:
"""#2985: Phase 2.5 удалена. Квартал без overflow с валидным bbox стоит ровно один
запрос к НСПД (search_by_quarter) отдельного grid-walk за ПЗЗ больше нет."""
from app.services.cadastre.bulk_harvest import harvest_quarter
snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:00+00:00",
features=[_make_parcel_feature()],
meta_counts={},
)
client = AsyncMock()
client.search_by_quarter = AsyncMock(return_value=snapshot)
progress_states: list[dict[str, Any]] = []
with (
patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert,
patch("app.services.cadastre.bulk_harvest.quarter_bbox_3857") as mock_bbox,
):
mock_bbox.return_value = (6735845.0, 8329000.0, 6736595.0, 8329750.0)
mock_upsert.return_value = dict.fromkeys(
("parcels", "buildings", "constructions", "oncs", "enks", "zouit", "skipped"), 0
)
await harvest_quarter(
db=_mock_db_grid_bbox(),
client=client,
quarter="66:41:0303161",
job_id=1,
update_progress=progress_states.append,
)
assert [c[0] for c in client.mock_calls] == ["search_by_quarter"]
assert [s["phase"] for s in progress_states] == ["snapshot_started", "snapshot_done", "done"]
# ── Issue #200: geom backfill для участков с geom IS NULL ─────────────────────

View file

@ -144,6 +144,23 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ─────────
# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют
# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по
# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься
# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны
# только для машины без базы.
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
@ -176,3 +193,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

@ -0,0 +1,134 @@
"""Починка разбора номера не удваивает land_reservation (#2982).
act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На
проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться,
строка (участок, «1413») с (участок, NULL) не конфликтует без очистки прогон
кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды.
Проверяется `_upsert_records` загрузчика целиком на временной таблице той же
формы: боевые данные не читаются и не меняются.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.workers.tasks.izyatie_ocr_ingest import _upsert_records
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
)
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _db_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
c.execute(text("SELECT 1"))
return True, ""
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _db_reachable()
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
_TABLE = """
CREATE TEMP TABLE land_reservation (
id bigserial PRIMARY KEY,
cad_num text NOT NULL,
reservation_kind text NOT NULL,
basis_act text NOT NULL,
act_number text,
act_date date,
purpose text,
doc_url text,
source text NOT NULL DEFAULT 'page_pdf',
is_active boolean NOT NULL DEFAULT true,
raw_excerpt text,
fetched_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number)
) ON COMMIT DROP;
"""
КАД = "66:41:0303004:22"
ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8"
def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict:
return {
"cad_num": cad,
"reservation_kind": "изъятие",
"basis_act": "Сообщение о планируемом изъятии",
"act_number": act,
"act_date": "2022-05-27",
"purpose": None,
"doc_url": url,
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
@pytest.fixture
def db():
engine = create_engine(_dsn())
session = sessionmaker(bind=engine)()
try:
session.execute(text(_TABLE))
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
yield session
finally:
session.rollback()
session.close()
engine.dispose()
def _rows(db) -> list[tuple[str, str | None, str]]:
return [
(r[0], r[1], r[2])
for r in db.execute(
text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3")
).all()
]
def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None:
"""Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером."""
_upsert_records(db, [_row(act=None)])
assert _rows(db) == [(КАД, None, ДОК)]
assert _upsert_records(db, [_row(act="1413")]) == 1
assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой"
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен"
def test_same_parcel_in_another_document_is_kept(db) -> None:
"""Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем."""
другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5"
_upsert_records(db, [_row(act="259", url=другой)])
_upsert_records(db, [_row(act=None)])
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)]
def test_other_parcels_of_same_document_are_kept(db) -> None:
"""Контроль: чистка идёт по участку, а не по всему документу."""
_upsert_records(db, [_row(cad="66:41:0303004:23", act=None)])
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)]

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

@ -0,0 +1,257 @@
"""Unit tests для poi_loader — региональный bbox + адаптивное дробление Overpass-тайлов.
Mock-based / pure БЕЗ живых походов в Overpass и без БД (правило: реальные запросы
к Overpass в тестах недопустимы). Покрывает:
- REGION_BBOX / DEFAULT_REGION дефолт остаётся "ekb", не меняется молча.
- _bbox_tiles для ЕКБ ровно один тайл, идентичный EKB_BBOX; для Москвы несколько
тайлов, покрывающих исходный bbox без дыр/нахлёста (по площади).
- _build_overpass_query bbox теперь параметр, а не глобальная константа.
- _fetch_category retry (было и раньше) + НОВОЕ: адаптивное дробление тайла на 4
четверти при устойчивом провале (вместо тихой потери тайла), с остановкой на
RECURSIVE_SPLIT_MAX_DEPTH (без бесконечной рекурсии).
"""
from __future__ import annotations
from types import SimpleNamespace
import httpx
import pytest
from app.services.site_finder.poi_loader import (
_MAX_CONSECUTIVE_TRANSPORT_ERRORS,
DEFAULT_REGION,
EKB_BBOX,
RECURSIVE_SPLIT_MAX_DEPTH,
REGION_BBOX,
OverpassUnreachableError,
_bbox_tiles,
_build_overpass_query,
_fetch_category,
_RunState,
_split_bbox_quadrants,
)
class _FakeResponse:
"""Ответ Overpass. ok=False отдаёт 504 — «запрос слишком тяжёлый», единственный
класс отказа, ради которого тайл вообще осмысленно дробить."""
def __init__(self, ok: bool, elements: list[dict] | None = None) -> None:
self._ok = ok
self._elements = elements or []
def raise_for_status(self) -> None:
if not self._ok:
raise httpx.HTTPStatusError(
"simulated Overpass overload",
request=httpx.Request("POST", "https://overpass-api.de/api/interpreter"),
response=httpx.Response(504),
)
def json(self) -> dict:
return {"elements": self._elements}
@pytest.fixture
def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""Подменяет asyncio.sleep внутри poi_loader на no-op — тесты дробления тайлов иначе
реально спали бы минуты (retry-пауза 3с + 1с между каждой из 4 четвертей на каждом
уровне рекурсии)."""
async def _instant(_seconds: float) -> None:
return None
monkeypatch.setattr("app.services.site_finder.poi_loader.asyncio.sleep", _instant)
# ── REGION_BBOX / дефолт ──────────────────────────────────────────────────────
def test_default_region_is_ekb_unchanged() -> None:
assert DEFAULT_REGION == "ekb"
assert REGION_BBOX["ekb"] == EKB_BBOX
def test_region_bbox_has_msk_product_core() -> None:
assert "msk" in REGION_BBOX
south, west, north, east = REGION_BBOX["msk"]
assert south < north
assert west < east
# ── _bbox_tiles ────────────────────────────────────────────────────────────────
def test_bbox_tiles_ekb_is_single_tile_identical_to_ekb_bbox() -> None:
"""Дефолтный регион не должен молча поменять поведение — один тайл, байт-в-байт EKB_BBOX."""
tiles = _bbox_tiles(EKB_BBOX)
assert tiles == [EKB_BBOX]
def test_bbox_tiles_msk_splits_into_multiple_tiles_without_gaps() -> None:
bbox = REGION_BBOX["msk"]
tiles = _bbox_tiles(bbox)
assert len(tiles) > 1
south, west, north, east = bbox
total_area = (north - south) * (east - west)
tiles_area = sum((t[2] - t[0]) * (t[3] - t[1]) for t in tiles)
assert tiles_area == pytest.approx(total_area, rel=1e-9)
# ── _split_bbox_quadrants ────────────────────────────────────────────────────────
def test_split_bbox_quadrants_covers_original_area() -> None:
bbox = (55.55, 37.30, 55.95, 37.90)
quads = _split_bbox_quadrants(bbox)
assert len(quads) == 4
south, west, north, east = bbox
total_area = (north - south) * (east - west)
quads_area = sum((q[2] - q[0]) * (q[3] - q[1]) for q in quads)
assert quads_area == pytest.approx(total_area, rel=1e-9)
# ── _build_overpass_query ─────────────────────────────────────────────────────
def test_build_overpass_query_uses_given_bbox_not_global_constant() -> None:
q = _build_overpass_query((("amenity", "pharmacy"),), (1.0, 2.0, 3.0, 4.0))
assert "(1.0,2.0,3.0,4.0)" in q
assert '["amenity"="pharmacy"]' in q
# ── _fetch_category: retry (существующее поведение) ──────────────────────────────
async def test_fetch_category_retries_then_succeeds(instant_sleep: None) -> None:
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
if calls["n"] == 1:
return _FakeResponse(ok=False)
return _FakeResponse(ok=True, elements=[{"type": "node", "id": 1, "lat": 1, "lon": 2}])
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0, 0, 1, 1), _RunState()
)
assert calls["n"] == 2
assert len(result) == 1
assert result[0]["_gd_category"] == "pharmacy"
# ── _fetch_category: адаптивное дробление (НОВОЕ) ─────────────────────────────────
async def test_fetch_category_splits_into_quadrants_on_persistent_failure(
instant_sleep: None,
) -> None:
"""Тайл, где оба attempt проваливаются, дробится на 4 четверти вместо потери данных."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
query = data["data"]
if "(0.0,0.0,1.0,1.0)" in query: # верхнеуровневый тайл всегда 504
return _FakeResponse(ok=False)
return _FakeResponse(
ok=True, elements=[{"type": "node", "id": calls["n"], "lat": 0.1, "lon": 0.1}]
)
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
# верхний тайл: 2 неудачных attempt, затем 4 успешных запроса по четвертям
assert calls["n"] == 2 + 4
assert len(result) == 4
async def test_fetch_category_gives_up_at_max_depth_without_infinite_recursion(
instant_sleep: None,
) -> None:
"""Тайл, падающий на ЛЮБОМ размере, останавливает дробление на RECURSIVE_SPLIT_MAX_DEPTH
и возвращает пустой список не зацикливается и не падает."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data # параметр используется — сигнатура должна совпадать с client.post
return _FakeResponse(ok=False)
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
assert result == []
# sum_{d=0}^{max_depth} 4^d узлов, каждый по 2 attempt — рекурсия конечна
expected_nodes = sum(4**d for d in range(RECURSIVE_SPLIT_MAX_DEPTH + 1))
assert calls["n"] == expected_nodes * 2
# ── отказ транспорта: НЕ дробим и не стучимся дальше ──────────────────────────
async def test_transport_error_does_not_split_the_tile(instant_sleep: None) -> None:
"""Connection refused говорит «хост нас не принимает», а не «запрос тяжёлый».
Дробление тут превращает один отказ в 4, 16, 64 повторных стука ровно это
случилось 15.09.2026 на загрузке Москвы (58 отказов на 4 успеха за три минуты).
"""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data
raise httpx.ConnectError("[Errno 101] Network is unreachable")
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
assert result == []
assert calls["n"] == 1 # ни retry, ни четвертей
async def test_consecutive_transport_errors_abort_the_run(instant_sleep: None) -> None:
"""Порог подряд идущих транспортных отказов останавливает ВЕСЬ прогон явной ошибкой."""
async def fake_post(_url: str, data: dict) -> _FakeResponse:
assert data
raise httpx.ConnectError("connection refused")
client = SimpleNamespace(post=fake_post)
state = _RunState()
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS - 1):
assert (
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
== []
)
with pytest.raises(OverpassUnreachableError):
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
async def test_successful_tile_resets_transport_error_streak(instant_sleep: None) -> None:
"""Одиночные сетевые икоты вперемешку с успехами не должны копиться до аварии."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data
if calls["n"] % 2:
raise httpx.ConnectError("hiccup")
return _FakeResponse(ok=True, elements=[{"type": "node", "id": calls["n"]}])
client = SimpleNamespace(post=fake_post)
state = _RunState()
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS * 2):
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
assert state.consecutive_transport_errors < _MAX_CONSECUTIVE_TRANSPORT_ERRORS

View file

@ -0,0 +1,38 @@
"""#2443: каталожные DOM.РФ-скраперы не должны попасть в beat, пока стоит блокировка.
С 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
Решение «выключено до прокси» раньше жило только в комментарии с обещанием
«cooldown 24-48h» и месяцами читалось как таймер. Здесь оно проверяется по
значению: включить запись = осознанно поправить этот тест после #3307.
"""
from __future__ import annotations
from unittest.mock import patch
_BLOCKED_TASKS = (
"tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
"tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
)
def test_domrf_catalog_scrapers_not_scheduled_while_stormwall_blocks() -> None:
from app.workers import beat_schedule
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
schedule = beat_schedule.build_beat_schedule()
# Контроль: статическая часть расписания реально построилась, иначе
# отсутствие задач ниже было бы зелёным по построению.
assert schedule["refresh-quarter-price-index"]["task"] == (
"tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain"
)
# По task, а не по ключу: переименованный ключ не должен обойти проверку.
scheduled = {
name: entry["task"] for name, entry in schedule.items() if entry["task"] in _BLOCKED_TASKS
}
assert scheduled == {}, (
f"каталог DOM.РФ включён в beat: {scheduled}. IP Poincare заблокирован "
"StormWall (#2443); включать только после прокси и принятого kn-прогона (#3307)"
)

View file

@ -133,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).
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
@ -155,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-фикстуру
@ -371,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 }`, так что путь и без этой строки не
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
@ -438,7 +518,7 @@ meraocenka.ru {
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
# robots.txt существует только в корне app/, не под mera-public/), эта
# форма под `{path}` не попадает — отсюда отдельный handle у него.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
@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 {
@ -469,7 +549,7 @@ meraocenka.ru {
# переносит query как есть. Обёртка `route` обязательна по той же
# причине, что у соседей — внутри `handle` порядок директив определяет
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
@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 {
route {
uri strip_suffix /
@ -557,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

View file

@ -56,6 +56,28 @@ errors.gendsgn.ru {
git.gendsgn.ru {
encode zstd gzip
# Генерация архивов закрыта — она съедала диск (16.09.2026).
#
# Forgejo на КАЖДЫЙ GET /<owner>/<repo>/archive/<ref>.{zip,tar.gz,bundle}
# собирает снапшот репозитория заново (20-30 МБ, несколько секунд CPU) и
# кладёт результат в кэш repo-archive. Штатная уборка archive_cleanup
# работает, но раз в сутки — а внешний обходчик (73 из 78 обращений за 6ч
# с одного адреса 216.73.216.17) дёргал архив для каждого коммита и держал
# генерацию на ~100 файлах в час. Кэш вырос до 55 ГБ при диске 145 ГБ и ел
# 2 ГБ/ч: до нуля оставалось ~12 часов.
#
# Ограничение скорости тут не лечит: даже один запрос в минуту — это
# 30 МБ/мин новых файлов при суточной уборке. Нужен именно отказ.
# На `git clone`, `git fetch` и работу CI это НЕ влияет: смарт-протокол
# ходит по /<owner>/<repo>/info/refs и /git-upload-pack, другие пути.
# Цена: анонимная кнопка «скачать архив» в вебе больше не работает —
# сознательный размен, вернуть можно снятием этих двух правил.
@repo_archive path_regexp repo_archive ^/[^/]+/[^/]+/archive/.+$
respond @repo_archive "Архивы отключены: используйте git clone" 403
@api_archive path_regexp api_archive ^/api/v1/repos/[^/]+/[^/]+/archive/.+$
respond @api_archive "Архивы отключены: используйте git clone" 403
reverse_proxy forgejo:3000
log {
@ -105,11 +127,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,77 @@ 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 + что-то ещё для остальных двух чисел.
#
# ТЕГ И ФЛАГИ СВЕРЕНЫ С ИСХОДНИКОМ ОБРАЗА, а не с памятью (#3493). Прежняя
# запись `0.13.0` + `--queue` не существовала вовсе: такого тега нет на Docker
# Hub (`pull` → `not found`), а в `src/cli.py` версии 0.12.2 опция зовётся
# `--queues` (список через запятую), `--queue` click отвергает при старте.
# Под `set -e` упавший `pull` обрывал ВСЮ джобу `agent-apps` деплоя метрик:
# с 12.09 до этой правки на Poincare не доезжало ничего из агента — ни этот
# экспортер, ни redis-exporter, ни новый конфиг Alloy, а NoActiveCeleryWorkers
# горел по ветке absent() при живом воркере.
#
# Имена метрик 0.12.2 (src/exporter.py, metric_prefix="celery_"):
# celery_worker_up{hostname}, celery_queue_length{queue_name},
# celery_task_failed_total — совпадают с ops/metrics/prometheus/rules/infra.yml.
celery-exporter:
image: danihodovic/celery-exporter:0.12.2
container_name: gendesign-celery-exporter
restart: unless-stopped
profiles: ["apps"]
command:
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
- "--queues=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"

View file

@ -1919,17 +1919,17 @@ export interface paths {
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
*
* Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint для ad-hoc
* запуска (smoke-тест после деплоя или повторный pass для свежесозданных
* объектов до next beat fire).
* Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
* Этот endpoint для ad-hoc запуска (smoke-тест или повторный pass для
* свежесозданных объектов).
*
* - max_objects=None дефолтный лимит таска (300).
* - max_objects=3 smoke-тест.
* - force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
*
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true
* beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
* re-trigger без явного подтверждения оператора запрещён.
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true
* beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
* запуск без явного подтверждения оператора запрещён (условие #3307).
*/
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
delete?: never;
@ -1960,9 +1960,9 @@ export interface paths {
* - max_flats=3 smoke-тест.
* - force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
*
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true
* same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
* того же WAF-бана.
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true
* same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
* блокировкой StormWall (условие #3307).
*/
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
delete?: never;

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

@ -10,9 +10,8 @@
# script says so loudly instead of the silence that let backups break for
# weeks undetected before (#71).
#
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
# Telegram once a backup is already known to be stale.
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an
# hourly cron doesn't spam Telegram once a backup is already known to be stale.
#
# Usage (cron — one line per sentinel, run more often than the backup itself
# so a stale state is caught promptly; hourly is a reasonable default for a
@ -27,9 +26,10 @@
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
# >> /var/log/gendesign-backup-staleness.log 2>&1
#
# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
# Alert channel: notify() in ops/lib-backup.sh — creds in
# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/
# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first
# two, logs only.
#
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
# used as a plain healthcheck by anything that just wants the exit code).

View file

@ -22,7 +22,11 @@
15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env
# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без
# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на
# этом хосте нет, — алерты молча не уходят никуда.
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
@ -33,11 +37,11 @@
# file-level копия работающего узла может оказаться рассогласованной. Время —
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
# чтобы три задачи не дрались за диск и сеть одновременно.
45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
# сутки плюс запас на сдвиг прогона.
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Уборка докера ───────────────────────────────────────────────────────────
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их

View file

@ -51,5 +51,10 @@ COUCHDB_S3_SECRET_KEY=
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
#TELEGRAM_BOT_TOKEN=
#TELEGRAM_CHAT_ID=
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
#BACKUP_ENV_FILE=/etc/default/gendesign-backup

View file

@ -27,6 +27,17 @@
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
# --- оповещения (Telegram) ---
# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без
# токена и чата notify() ищет их в /etc/default/gendesign-backup.
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
# the policy's Resource path exactly

View file

@ -24,13 +24,8 @@
#
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
# /etc/default/gendesign-uptime — deliberately a separate config so backup
# alerting doesn't depend on the uptime watchdog's env file existing).
# SAME variable names as ops/gendesign-uptime.default.example — point both
# files at the same bot/chat if you want one Telegram destination for
# everything, or use different bots/chats if you'd rather split the noise.
# Without these two set, ops/check-backup-staleness.sh still logs, just
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file.
# Without the first two set, ops/check-backup-staleness.sh still logs, just
# doesn't send a Telegram alert.
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
@ -41,9 +36,14 @@
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
# --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
# --- missed-run alerting (Telegram) ---
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and

View file

@ -1,28 +0,0 @@
# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75).
#
# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host
# runs the cron (ideally a host OTHER than the prod VM, so it survives a full
# VPS outage):
# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime
# sudo chmod 600 /etc/default/gendesign-uptime
# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds
#
# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set,
# it still logs up/down but sends no alert (useful for a dry run first).
#
# Get a bot token from @BotFather; get your chat_id by messaging the bot then
# GET https://api.telegram.org/bot<TOKEN>/getUpdates and reading message.chat.id.
# --- Telegram alerting (both required to enable alerts) ---
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#BASE_URL=https://gendsgn.ru
#STATE_FILE=/var/tmp/gendesign-uptime-state
#CURL_TIMEOUT=15
#RETRIES=2
#RETRY_SLEEP=5
# Custom check list (newline-separated "label|path|expected_status"):
#CHECKS="health|/health|200
#market-pulse|/api/v1/analytics/market-pulse|200"

View file

@ -5,9 +5,7 @@
# SOURCED, not executed directly — no shebang execution of its own. Inherits
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
# notify() when Telegram vars are actually set — curl is already a hard
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
# new dependency).
# notify() when Telegram vars are actually set).
#
# Load with (script computes its own dir so this works regardless of cron's
# CWD or which repo subdir the caller lives in):
@ -18,14 +16,8 @@
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# --- notify ------------------------------------------------------------
# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
# backup alerting doesn't depend on the uptime watchdog's config being
# present, and vice versa. Point both files at the same bot/chat if you want
# one Telegram destination for everything — that's an ops choice, not this
# script's concern.
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env
# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164.
#
# No-op (logs only) when unset — this is the extension point: to wire a
# different channel later, edit ONLY this function; every caller in this repo
@ -153,12 +145,9 @@ sentinel_age_hours() {
}
# --- transition-tracked alert state --------------------------------------
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
# flat "<label> <status>" file, one line per label, so repeated runs alert
# A flat "<label> <status>" file, one line per label, so repeated runs alert
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
# single run — avoids Telegram spam from an hourly staleness-check cron.
# Intentionally a separate, independent implementation (not shared code with
# uptime-healthcheck.sh) — that script is out of scope for this change.
backup_prev_status() {
local state_file="$1" label="$2" v
[[ -f "$state_file" ]] || { echo "unknown"; return; }

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,184 @@
{
"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": "timeseries",
"title": "Конверсия оценка → заявка, скользящие 7 суток",
"description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
"targets": [
{
"refId": "A",
"expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))",
"legendFormat": "заявки / оценки"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
},
{ "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,17 +140,225 @@ 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)`
# выбрасывает серии без лимита ДО деления.
#
# ВТОРОЕ УСЛОВИЕ — АНОНИМНАЯ ПАМЯТЬ (#3493). working_set = usage inactive_file,
# то есть в нём остаётся АКТИВНЫЙ кэш страниц, а его ядро вытесняет само и
# OOM из-за него не наступает. У контейнера с базой ночные сканы поднимают
# активный кэш к потолку каждую ночь: tradein-postgres за 14 суток дал 5
# эпизодов (все 01:0903:44 UTC) при анонимной памяти не выше 11.4 % лимита и
# oom_kill = 0 — текст «дальше OOM-kill» был ложью. Калибровка этим самым
# выражением по истории 14 суток: tradein-postgres 5 → 0, tradein-browser
# 2 → 2 (06.09 и 12.09, rss до 95 % — настоящие), остальные 0 → 0.
# `and` без `on()`: у working_set и rss один и тот же набор меток cAdvisor,
# совпадение проверено на проде (913 из 915 серий); слева по-прежнему доля.
- 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
and
(container_memory_rss{name!=""}
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.50)
for: 15m
labels:
severity: warning
annotations:
summary: "Контейнер у своего потолка памяти"
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
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:
# Бэкенд «Меры» лежит или завис (#2214). До этого правила такой отказ не
# давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на
# лэндинг meraocenka.ru, а его отдаёт tradein-frontend из пререндер-кэша
# (x-nextjs-cache: HIT) — при мёртвом бэкенде там по-прежнему 200.
# Монитор 2 (gendsgn.ru/health) — это бэкенд «Птицы». Два правила ниже
# считают метрики, которые отдаёт сам бэкенд: мёртвый не отдаёт, и они
# молчат по построению.
#
# Скрейп идёт с Poincare (Alloy), но решение и доставка — здесь, на Beget.
# Зависший event loop даёт таймаут скрейпа (20s), удалённый контейнер —
# ошибку DNS; в обоих случаях up=0. Смерть всего хоста ловят
# HostAgentDown/RemoteWriteStalled; `absent()` — на случай, когда цель
# тихо пропала из alloy-apps.alloy.
#
# for: 5m — по истории up{app="mera"} 26.0817.09 (61 857 точек): 140
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
#
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
# заводится в UI GlitchTip, а не кодом.
- alert: MeraBackendDown
expr: |
up{job="app", app="mera"} == 0
or absent(up{job="app", app="mera"})
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Гейт по 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 0.12.2 (src/exporter.py) — см.
# комментарий у сервиса в docker-compose.metrics-agent.yml (#3493).
- 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."
# ДВЕ РАЗНЫЕ НОВОСТИ — ДВА ПРАВИЛА (#3493). Прежнее
# `count(celery_worker_up == 1) == 0 or absent(celery_worker_up)` смешивало
# «воркер мёртв» с «экспортёр не поднят» под текстом первой: с 12.09 по 17.09
# оно горело без перерыва по ветке absent() при живом воркере (экспортёр не
# стартовал), повторяясь каждые ~3 часа. А первая ветка не сработала бы
# НИКОГДА: `count()` от пустого вектора возвращает пустой вектор, а не 0,
# поэтому `== 0` сравнивать не с чем — мёртвый воркер она бы пропустила.
#
# Здесь — только когда экспортёр ЖИВ (`up == 1`), но живых воркеров нет:
# либо все серии в 0, либо серий нет (экспортёр вычищает отвалившихся).
# Отсутствие самого экспортёра — QueueExporterDown ниже, со своим текстом.
- alert: NoActiveCeleryWorkers
expr: |
(sum(celery_worker_up) == 0 or absent(celery_worker_up))
and on() (up{job="celery"} == 1)
for: 5m
labels:
severity: critical
annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# Экспортёр очереди не отдаёт метрики. `absent(up{job="X"})` переносит
# job в метки результата, поэтому текст знает, какой из двух.
- alert: QueueExporterDown
expr: |
up{job=~"redis|celery"} == 0
or absent(up{job="redis"})
or absent(up{job="celery"})
for: 10m
labels:
severity: warning
annotations:
summary: "Метрики очереди не собираются"
description: "{{ $labels.job }}-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
# Порог 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
@ -172,12 +395,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 +416,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

View file

@ -0,0 +1,172 @@
# Юнит-тесты правил `promtool test rules` (#3493). Исполняются деплоем метрик
# перед reload Prometheus (.forgejo/workflows/deploy-metrics.yml): упавший тест
# оставляет работающий Prometheus на прежних правилах.
#
# Каждый случай — ровно тот, что уже случился на проде, а не придуманный.
rule_files:
- ../rules/infra.yml
evaluation_interval: 1m
tests:
# tradein-postgres, ночь 17.09: рабочий набор 98 % от лимита, но почти весь —
# кэш страниц; анонимная память ~1 %. OOM не грозит — тревоги быть не должно.
- interval: 1m
input_series:
- series: 'container_memory_working_set_bytes{host="apps",name="tradein-postgres"}'
values: '2940x40'
- series: 'container_memory_rss{host="apps",name="tradein-postgres"}'
values: '40x40'
- series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-postgres"}'
values: '3000x40'
alert_rule_test:
- eval_time: 30m
alertname: ContainerNearMemoryLimit
exp_alerts: []
# tradein-browser, 12.09: у потолка, и это анонимная память незакрытых
# инстансов браузера. Тревога обязана прийти, и в тексте — доля, а не байты.
- interval: 1m
input_series:
- series: 'container_memory_working_set_bytes{host="apps",name="tradein-browser"}'
values: '1960x40'
- series: 'container_memory_rss{host="apps",name="tradein-browser"}'
values: '1900x40'
- series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-browser"}'
values: '2000x40'
alert_rule_test:
- eval_time: 30m
alertname: ContainerNearMemoryLimit
exp_alerts:
- exp_labels:
severity: warning
host: apps
name: tradein-browser
exp_annotations:
summary: "Контейнер у своего потолка памяти"
description: "apps / tradein-browser: 98% от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill."
# 12.0917.09: экспортёр Celery не поднят, серий нет вовсе, воркер при этом жив.
# Должна гореть QueueExporterDown про celery — и НЕ должна NoActiveCeleryWorkers.
- interval: 1m
input_series:
- series: 'up{job="redis",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 30m
alertname: NoActiveCeleryWorkers
exp_alerts: []
- eval_time: 30m
alertname: QueueExporterDown
exp_alerts:
- exp_labels:
severity: warning
job: celery
exp_annotations:
summary: "Метрики очереди не собираются"
description: "celery-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
# Нормальная работа: экспортёр жив, воркер шлёт heartbeat — тишина.
- interval: 1m
input_series:
- series: 'up{job="celery",host="apps"}'
values: '1x40'
- series: 'up{job="redis",host="apps"}'
values: '1x40'
- series: 'celery_worker_up{hostname="celery@worker",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 30m
alertname: NoActiveCeleryWorkers
exp_alerts: []
- eval_time: 30m
alertname: QueueExporterDown
exp_alerts: []
# Воркер умер: экспортёр жив, серия воркера в 0. Прежнее правило
# (`count(x == 1) == 0`) этот случай пропускало — count() от пустого вектора пуст.
- interval: 1m
input_series:
- series: 'up{job="celery",host="apps"}'
values: '1x40'
- series: 'celery_worker_up{hostname="celery@worker",host="apps"}'
values: '0x40'
alert_rule_test:
- eval_time: 30m
alertname: NoActiveCeleryWorkers
exp_alerts:
- exp_labels:
severity: critical
exp_annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# Воркер умер давно: экспортёр вычистил его серию, осталось только `up`.
- interval: 1m
input_series:
- series: 'up{job="celery",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 30m
alertname: NoActiveCeleryWorkers
exp_alerts:
- exp_labels:
severity: critical
exp_annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# #2214: tradein-backend умер, хост и агент живы — агент шлёт up=0.
# До MeraBackendDown этот случай не давал ни одного сигнала вне Poincare.
- interval: 30s
input_series:
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
values: '1x9 0x40'
alert_rule_test:
- eval_time: 15m
alertname: MeraBackendDown
exp_alerts:
- exp_labels:
severity: critical
host: apps
job: app
app: mera
instance: tradein-backend:8000
exp_annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Окно деплоя: самый длинный провал за 26.0817.09 — 4 нулевые точки подряд
# (~2 минуты). Тревоги быть не должно. Заодно упавший бэкенд «Птицы» не должен
# поднимать тревогу про «Меру».
- interval: 30s
input_series:
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
values: '1x19 0 0 0 0 1x40'
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
values: '0x64'
alert_rule_test:
- eval_time: 11m
alertname: MeraBackendDown
exp_alerts: []
- eval_time: 30m
alertname: MeraBackendDown
exp_alerts: []
# Цель пропала из скрейпа (агент жив, «Птица» снимается), серии «Меры» нет.
- interval: 30s
input_series:
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 10m
alertname: MeraBackendDown
exp_alerts:
- exp_labels:
severity: critical
host: apps
job: app
app: mera
exp_annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."

View file

@ -0,0 +1,65 @@
#!/bin/sh
# Пересоздать контейнер, если он читает СТАРЫЙ инод пофайлового bind-маунта
# (#3467, #3486).
#
# sh ops/metrics/recreate-stale-mount.sh ПРОЕКТ COMPOSE-ФАЙЛ СЕРВИС ФАЙЛ-НА-ХОСТЕ ПУТЬ-В-КОНТЕЙНЕРЕ
#
# ЗАЧЕМ. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
# одного файла держит прежний: контейнер продолжает читать старый текст, `up -d`
# этого не видит (сравнивает описание сервиса, а не содержимое маунта), и
# перезагрузка по API тоже — процесс перечитывает путь, за которым в контейнере
# всё тот же старый инод. Проверено на prom/prometheus:v3.1.0 (17.09): файл
# подменён, POST /-/reload → rc=0, а в /api/v1/status/config остался прежний
# job. Лечит только пересоздание контейнера — его и делаем, но лишь при
# расхождении, чтобы не рвать работу на каждом деплое.
#
# Та же ловушка уже ловилась на Alertmanager (27.08), Alloy (#3380) и Caddy
# (#3443, ops/caddy-apply.sh). Контейнер не запущен — старого инода держать
# некому (при старте маунт берётся с хоста заново), выходим с 0. Сервис
# выключенного профиля сюда передавать нельзя: `up` с явным именем включает
# профиль и поднял бы сервис без окружения — гейт по профилю у вызывающего.
# Сверка не прочиталась — считаем расхождением. После пересоздания сверяем
# снова: не сошлось — exit 1, а не зелёный деплой со старым конфигом.
set -eu
if [ "$#" -ne 5 ]; then
echo "usage: $0 PROJECT COMPOSE_FILE SERVICE HOST_FILE CONTAINER_FILE" >&2
exit 2
fi
project=$1 compose_file=$2 service=$3 src=$4 dst=$5
cd "$(dirname "$0")/../.."
# Ошибку `ps` НЕ глушим: пустой ответ от упавшего compose читался бы как
# «контейнер не запущен» — и зелёный выход со старым конфигом.
cid() { docker compose -p "$project" -f "$compose_file" ps -q "$service"; }
seen() { docker exec "$1" stat -c %i "$dst" 2>/dev/null || true; }
id=$(cid)
if [ -z "$id" ]; then
echo "$service: контейнер не запущен — старый инод $src держать некому."
exit 0
fi
want=$(stat -c %i "$src")
if [ "$(seen "$id")" = "$want" ]; then
echo "$service: $src доехал до контейнера (инод $want)."
exit 0
fi
echo "$service: контейнер не видит текущий $src (инод на хосте $want) — пересоздаю."
docker compose -p "$project" -f "$compose_file" up -d --no-deps --force-recreate "$service"
i=0
while [ "$i" -lt 15 ]; do
id=$(cid)
if [ -n "$id" ] && [ "$(seen "$id")" = "$want" ]; then
echo "$service: пересоздан, $src доехал (инод $want)."
exit 0
fi
i=$((i + 1))
sleep 2
done
echo "::error::$service: после пересоздания контейнер всё ещё не видит $src (инод $want)"
exit 1

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

@ -216,7 +216,7 @@ log "11. Закрепление рабочего адреса api.telegram.org"
#
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
# ops/lib-backup.sh (уведомления о бэкапах) и ops/uptime-healthcheck.sh.
# ops/lib-backup.sh (уведомления о бэкапах).
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым
# мы узнали бы о любой другой поломке.
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"

View file

@ -1,171 +0,0 @@
#!/usr/bin/env bash
# External uptime watchdog for gendesign (#75 B6-1, lightweight fallback).
#
# WHY THIS EXISTS alongside Uptime Kuma (docker-compose.uptime.yml): Kuma runs
# ON the prod VM, so if the whole VM dies it can't alert. This script is meant to
# run from cron on a DIFFERENT host (your laptop, a tiny free-tier box, Beget
# shared-host cron) and hit the PUBLIC URLs over the internet — last-resort
# "весь хост лёг" detection. Kuma covers rich per-endpoint/SSL/latency monitoring;
# this covers the case Kuma structurally can't.
#
# Self-contained: only needs `curl` + `bash`. No docker, no repo checkout.
# State (last-known status per check) lives in a file so we alert on TRANSITIONS
# (up→down, down→up) — not every run — to avoid Telegram spam.
#
# Usage (cron — note `bash`, not a bare path, so a missing +x bit can't break it):
# * * * * * bash /path/to/uptime-healthcheck.sh >> /var/log/gendesign-uptime.log 2>&1
#
# Telegram alerting — set these in an env file (NOT in git, chmod 600):
# TELEGRAM_BOT_TOKEN=123456:ABC...
# TELEGRAM_CHAT_ID=123456789
# Default env path: /etc/default/gendesign-uptime (override via UPTIME_ENV_FILE).
# A redacted template lives at ops/gendesign-uptime.default.example.
# Without a token set, the script still logs up/down but sends no alert.
set -euo pipefail
# --- config (env-overridable) ---
UPTIME_ENV_FILE="${UPTIME_ENV_FILE:-/etc/default/gendesign-uptime}"
# shellcheck source=/dev/null
[[ -f "$UPTIME_ENV_FILE" ]] && source "$UPTIME_ENV_FILE"
BASE_URL="${BASE_URL:-https://gendsgn.ru}"
STATE_FILE="${STATE_FILE:-/var/tmp/gendesign-uptime-state}"
CURL_TIMEOUT="${CURL_TIMEOUT:-15}" # seconds per request (connect+read)
RETRIES="${RETRIES:-2}" # extra attempts before declaring DOWN
RETRY_SLEEP="${RETRY_SLEEP:-5}" # seconds between attempts
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
# Checks to probe. Format per line: "<label>|<path>|<expected_http_status>".
# Override the whole list via CHECKS env (same newline-separated format).
#
# DEFAULT = /health ONLY. WHY: this watchdog is UNAUTHENTICATED, and in the
# Caddyfile only /health (and /preview/*) are public — ALL /api/* sits behind
# the Basic-Auth gate (import caddy/users.caddy.snippet) and returns 401 to an
# anonymous client. So the issue's /api/v1/analytics/market-pulse and
# /parcels/{cad}/analyze monitors CANNOT be probed anonymously — they belong in
# Uptime Kuma, which can attach the Basic-Auth header (see README "Kuma monitors"
# in docker-compose.uptime.yml). Probing them here would false-alarm forever.
CHECKS="${CHECKS:-health|/health|200}"
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# --- telegram (no-op if token/chat unset) ---
notify() {
local text="$1"
if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then
log "NOTIFY (telegram disabled — no token/chat): $text"
return 0
fi
# #3059: путь до Telegram теряет соединения. Замер 26.08 с Poincare — 40
# подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.154.167.220: 3 отказа (7.5%), все
# таймаутом на установке соединения; успешные при этом стабильны (0.14-0.17 с).
# Три остальных дата-центра Telegram с Selectel недостижимы вовсе, так что
# запасного адреса нет — потери на единственном рабочем неустранимы сетью.
#
# Раньше здесь был ОДИН curl, и `|| log WARN` означал, что каждый такой отказ
# ТЕРЯЕТ алерт целиком: уведомление о падении прода не приходит, остаётся
# строка в логе, который читают уже после аварии. Watchdog, который сам себя
# не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела,
# тем вероятнее, что о них не сообщат.
#
# Цикл, а не `curl --retry`: ниже в этом же файле проверки уже повторяются
# ровно такой конструкцией (см. `for attempt in $(seq 1 ...)`), и семантика
# `--max-time` при ретраях curl зависит от версии. Здесь таймаут заведомо
# применяется к КАЖДОЙ попытке.
#
# Дубль вместо потери — осознанный размен: sendMessage не идемпотентен, но
# замер показал, что отказы происходят на СТАДИИ ПОДКЛЮЧЕНИЯ, до отправки
# запроса, так что повтор почти никогда не дублирует уже доставленное
# сообщение. А продублированный алерт безвреден, пропущенный — нет.
local attempt
for attempt in 1 2 3; do
if curl -fsS --max-time "$CURL_TIMEOUT" \
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \
-d "disable_web_page_preview=true" \
--data-urlencode "text=${text}" \
>/dev/null 2>&1; then
[[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}"
return 0
fi
[[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}"
done
log "WARN: telegram sendMessage failed — 3 попытки подряд, алерт НЕ ДОСТАВЛЕН"
}
# --- state helpers (last status per check) ---
prev_status() {
local label="$1"
[[ -f "$STATE_FILE" ]] || { echo "unknown"; return; }
# Line format: "<label> <status>". grep the latest for this label.
local v
v="$(grep -E "^${label} " "$STATE_FILE" 2>/dev/null | tail -1 | awk '{print $2}')"
echo "${v:-unknown}"
}
set_status() {
local label="$1" status="$2" tmp
tmp="$(mktemp)"
# Drop any prior line for this label, then append the fresh one.
if [[ -f "$STATE_FILE" ]]; then
grep -vE "^${label} " "$STATE_FILE" > "$tmp" 2>/dev/null || true
fi
echo "${label} ${status}" >> "$tmp"
mv "$tmp" "$STATE_FILE"
}
# --- probe one URL with retries; echoes "up" or "down code=NNN" ---
probe() {
local url="$1" expect="$2" attempt code
# NB: NO `-f` here. `-w %{http_code}` always prints exactly a 3-digit status
# (or 000 on connect/timeout failure), so we judge by the code ourselves and
# never need a `|| echo` fallback (which previously concatenated → "401000").
for attempt in $(seq 1 "$((RETRIES + 1))"); do
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" "$url" 2>/dev/null)"
code="${code:-000}"
if [[ "$code" == "$expect" ]]; then
echo "up"
return 0
fi
[[ "$attempt" -le "$RETRIES" ]] && sleep "$RETRY_SLEEP"
done
# Report the last code seen for the alert body.
echo "down code=${code}"
return 0
}
# --- run ---
overall_rc=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
label="${line%%|*}"
rest="${line#*|}"
path="${rest%%|*}"
expect="${rest##*|}"
url="${BASE_URL}${path}"
result="$(probe "$url" "$expect")"
now="up"
[[ "$result" == up ]] || now="down"
prev="$(prev_status "$label")"
if [[ "$now" == "up" ]]; then
log "OK $label ($url)"
if [[ "$prev" == "down" ]]; then
notify "✅ RECOVERED: gendesign $label is back UP ($url)"
fi
else
overall_rc=1
log "DOWN $label ($url) — $result"
# Alert on transition into down (or first-ever observation that is down).
if [[ "$prev" != "down" ]]; then
notify "🔴 DOWN: gendesign $label${url} (${result#down }). $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
fi
fi
set_status "$label" "$now"
done <<< "$CHECKS"
exit "$overall_rc"

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

@ -63,6 +63,7 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
from scraper_kit.providers.yandex.valuation import YandexValuationScraper
from scraper_kit.proxy_errors import NoProxyAvailableError
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -516,16 +517,13 @@ async def cian_auto_login(
)
try:
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
# #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login`
# сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды.
# До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара —
# на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе.
async with build_browser_fetcher(
RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider()
) as fetcher:
raw_cookies = await fetcher.login(
url=settings.cian_login_url,
email=email,
@ -537,6 +535,13 @@ async def cian_auto_login(
pre_click_selectors=settings.cian_login_pre_click_selectors,
wait_ms=settings.cian_login_wait_ms,
)
except NoProxyAvailableError as exc:
# Пул пуст для cian (прод, #2616): на мёртвый env-узел не идём, говорим прямо.
logger.warning("cian auto-login: пул прокси пуст для cian — логин не запускался")
raise HTTPException(
status_code=503,
detail="Нет свободного узла прокси для cian в пуле — логин не запускался",
) from exc
except Exception as exc:
logger.error("cian auto-login failed: %s", type(exc).__name__)
raise HTTPException(
@ -1252,8 +1257,11 @@ async def start_avito_city_sweep(
request_delay_sec=payload.request_delay_sec,
enrich_imv=payload.enrich_imv,
)
except Exception:
except Exception as exc:
logger.exception("city-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally:
sweep_db.close()
@ -1344,8 +1352,11 @@ async def start_cian_city_sweep(
detail_top_n=payload.detail_top_n,
enrich_houses=payload.enrich_houses,
)
except Exception:
except Exception as exc:
logger.exception("cian-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally:
sweep_db.close()
@ -1487,8 +1498,11 @@ async def start_cian_full_load(
resume_run_id=payload.resume_run_id,
secondary_only=payload.secondary_only,
)
except Exception:
except Exception as exc:
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(task_db, run_id, exc)
finally:
task_db.close()
@ -1590,8 +1604,11 @@ async def start_yandex_full_load(
concurrency=payload.concurrency,
resume_run_id=payload.resume_run_id,
)
except Exception:
except Exception as exc:
logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(task_db, run_id, exc)
finally:
task_db.close()
@ -1658,8 +1675,11 @@ async def start_yandex_city_sweep(
request_delay_sec=payload.request_delay_sec,
enrich_address=payload.enrich_address,
)
except Exception:
except Exception as exc:
logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally:
sweep_db.close()

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

@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
from __future__ import annotations
import asyncio
import json
import logging
import secrets
@ -50,10 +51,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__)
@ -70,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
_INTERACTIVE_SEND_MAX_RETRIES = 1
# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос,
# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор
# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую +
# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0),
# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному
# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и
# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело.
_INTERACTIVE_SEND_DEADLINE_S = 7.0
class GlitchTipAttachment(BaseModel):
@ -183,16 +196,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,8 +238,12 @@ 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:
async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S):
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
text=text,
@ -224,8 +253,31 @@ async def glitchtip_webhook(
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
except (TelegramError, TimeoutError):
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 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

@ -2,9 +2,16 @@
POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. Notification (Telegram/email) вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
trade_in_leads.
Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид
уходит сообщением в support-топик Telegram тот же бот и топик, что у веб-чата
поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и
никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ
Telegram не трогает ни ответ, ни лид остаётся лог и пустой `notified_at` как
видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается
механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id
телефон оператор берёт из trade_in_leads.
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца любой
@ -25,14 +32,17 @@ from datetime import UTC, datetime, timedelta
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.support import _bot_configured
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.core.db import SessionLocal, get_db
from app.observability.metrics import LEADS
from app.services.tgbot.shared import get_telegram_client
logger = logging.getLogger(__name__)
@ -98,11 +108,49 @@ class TradeInLeadInput(BaseModel):
return value
async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None:
"""Сообщает о заявке в support-топик и отмечает `notified_at` (#1971).
Идёт после ответа клиенту, поэтому ретраи клиента штатные воркерные, без
интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя:
сессия запроса к этому моменту уже закрыта. Любой отказ только в лог.
"""
message = (
"Новая заявка на трейд-ин\n"
f"id: {lead_id}\n"
f"пользователь: {username or ''}\n"
f"оценка: {estimate_id or 'без привязки'}\n"
"Телефон — в trade_in_leads по id."
)
try:
await get_telegram_client().send_message(
chat_id=settings.telegram_support_chat_id,
text=message,
message_thread_id=settings.telegram_support_topic_id or None,
)
except Exception:
logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id)
return
db = SessionLocal()
try:
db.execute(
text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"),
{"id": lead_id},
)
db.commit()
except Exception:
logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id)
finally:
db.close()
@router.post("/lead")
async def create_trade_in_lead(
payload: TradeInLeadInput,
request: Request,
db: Annotated[Session, Depends(get_db)],
background_tasks: BackgroundTasks,
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict[str, Any]:
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
@ -173,6 +221,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",
@ -183,6 +232,15 @@ async def create_trade_in_lead(
_CONSENT_POLICY_VERSION,
)
if _bot_configured():
background_tasks.add_task(
_notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user
)
else:
logger.warning(
"trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"]
)
return {
"id": row["id"],
"created_at": row["created_at"].isoformat(),

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,6 +506,14 @@ async def send_support_message(
)
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
#
# Оборотная сторона этого порядка: отказ БД здесь означает, что сообщение
# оператору УЖЕ доставлено. Отдавать на это 500 (как было) — худший из
# вариантов: клиент видит ошибку, шлёт повторно, в топике дубль, а на
# осиротевшее зеркало оператор отвечает в пустоту. Поэтому отвечаем успехом
# (доставка правда состоялась) и отдельным сообщением предупреждаем
# оператора, что отвечать на это зеркало бесполезно.
try:
thread_id = storage.get_or_create_thread(db, username)
row = storage.record_inbound(
db,
@ -260,8 +521,20 @@ async def send_support_message(
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,6 +755,10 @@ async def send_anon_support_message(
display_id,
)
# Отказ БД после успешной отправки — см. развёрнутый комментарий в парной
# (авторизованной) ручке: сообщение оператору уже доставлено, 500 тут создаёт
# дубли в топике и «осиротевшее» зеркало, на которое оператор отвечает зря.
try:
thread_id = storage.get_or_create_thread(db, thread_key)
row = storage.record_inbound(
db,
@ -444,8 +766,26 @@ async def send_anon_support_message(
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,
@ -30,6 +31,7 @@ from app.schemas.trade_in import (
CoverageProbeInput,
CoverageProbeResponse,
DkpCorridor,
ExposureWindow,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
HouseInfoForEstimate,
@ -389,6 +391,7 @@ async def _try_revive_dead_estimate(
),
{"orig": str(estimate_id), "temp": str(temp_id)},
)
w = result.exposure_window # #2898: окно целиком или NULL целиком
db.execute(
text(
"""
@ -417,6 +420,10 @@ async def _try_revive_dead_estimate(
ratio_basis = :ratio_basis,
relaxations = CAST(:relaxations_json AS jsonb),
reliability = :reliability,
est_days_p25 = :est_days_p25,
est_days_p50 = :est_days_p50,
est_days_p75 = :est_days_p75,
est_days_n = :est_days_n,
revival_completed_at = NOW()
WHERE id = CAST(:id AS uuid)
"""
@ -451,6 +458,10 @@ async def _try_revive_dead_estimate(
"ratio_basis": result.ratio_basis,
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
"reliability": result.reliability,
"est_days_p25": w.p25_days if w else None,
"est_days_p50": w.p50_days if w else None,
"est_days_p75": w.p75_days if w else None,
"est_days_n": w.n if w else None,
},
)
db.execute(
@ -560,6 +571,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.
@ -670,7 +688,8 @@ def load_estimate(
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, created_by, created_at,
relaxations, reliability
relaxations, reliability,
est_days_p25, est_days_p50, est_days_p75, est_days_n
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
AND {ESTIMATE_READABLE_SQL}
@ -710,6 +729,7 @@ def load_estimate(
_qc_geo_to_precision,
_resolve_target_city,
_source_counts,
analog_tier_from_explanation,
rehydrate_search_radius_m,
)
@ -834,6 +854,19 @@ def load_estimate(
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
# ответ AttributeError'ом.
market_percentile=getattr(row, "market_percentile", None),
# #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке.
# getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError.
exposure_window=(
ExposureWindow(
p25_days=row.est_days_p25,
p50_days=row.est_days_p50,
p75_days=row.est_days_p75,
n=row.est_days_n,
)
if getattr(row, "est_days_n", None) is not None
else None
),
est_days_on_market=getattr(row, "est_days_p50", None),
period_months=12,
analogs=analogs,
actual_deals=actual_deals,
@ -888,6 +921,10 @@ def load_estimate(
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
search_radius_m=search_radius_m,
# #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи
# якорного блока в confidence_explanation; радиусный — null, колонки нет.
# Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A).
analog_tier=analog_tier_from_explanation(row.confidence_explanation),
)
@ -1013,6 +1050,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,
@ -2201,6 +2239,7 @@ def get_street_deals(
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
now = datetime.now(tz=UTC)
@ -2244,6 +2283,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(
@ -2255,6 +2305,7 @@ def get_street_deals(
AND address ILIKE :street_pattern
AND address ~* :street_regex
{city_filter}
AND region_code = CAST(:region_code AS integer)
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
@ -2269,6 +2320,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,
"region_code": region_code,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -2488,8 +2540,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.
@ -2499,7 +2555,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(
@ -2529,16 +2590,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),
@ -2547,7 +2628,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)
"""
),
{
@ -2558,6 +2641,7 @@ def get_sales_vs_listings(
"area_tolerance": area_tolerance,
"period_months": period_months,
"target_city": target_city,
"region_code": region_code,
},
)
.mappings()
@ -2789,6 +2873,46 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12
# Московская область (регион 50) добавлена 2026-09-16 — все 22 города сразу в
# жёлтый тир, той же метрикой, которой выбирали тир Москве. Замер: 120 проб на
# город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь
# +-15% + scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты по
# городам области 17-51, доля проб с когортой >= 12 равна 0.63-0.92 (Видное
# 51/0.83, Балашиха 40/0.88, Железнодорожный 39/0.92, Люберцы 37/0.87,
# Электросталь 35/0.83, Химки 30/0.74, Домодедово 30/0.79, Ногинск 29/0.85,
# Реутов 28/0.82, Долгопрудный 27/0.72, Красногорск 27/0.77, Одинцово 27/0.78,
# Подольск 26/0.83, Сергиев Посад 25/0.79, Королёв 24/0.79, Мытищи 23/0.73,
# Орехово-Зуево 22/0.73, Серпухов 22/0.66, Раменское 21/0.68,
# Наро-Фоминск 20/0.73, Щёлково 19/0.73, Коломна 17/0.63). Контроль той же
# метрикой: Москва (уже жёлтая) — медиана 14, доля 0.57; Нижний Тагил (тоже
# жёлтый) — медиана 11, доля 0.473. То есть КАЖДЫЙ город области плотнее уже
# принятого жёлтого эталона (Москвы) — зелёный тир не рассматривался, порог
# 12 достаточен и единообразен. Порядок — по убыванию медианы когорты.
COVERAGE_MO_CITIES = (
"Видное",
"Балашиха",
"Железнодорожный",
"Люберцы",
"Электросталь",
"Химки",
"Домодедово",
"Ногинск",
"Реутов",
"Долгопрудный",
"Красногорск",
"Одинцово",
"Подольск",
"Сергиев Посад",
"Королёв",
"Мытищи",
"Орехово-Зуево",
"Серпухов",
"Раменское",
"Наро-Фоминск",
"Щёлково",
"Коломна",
)
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
@ -2985,6 +3109,7 @@ def _fold_city(name: str) -> str:
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_MO_CITIES},
**{
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
for k in COVERAGE_MOSCOW_CENTROID_KEYS
@ -3031,6 +3156,36 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
"Серов": (59.6047, 60.5772),
# Московская область (регион 50), 22 города — добавлены 2026-09-16 вместе с
# COVERAGE_MO_CITIES (см. обоснование порога там же). Координаты для 12 из
# 22 перенесены как есть из `_COVERAGE_NEGATIVE_CENTROIDS_DEG` (эти города
# раньше были ТОЛЬКО отрицательными точками московской сетки — конкурс
# ближайшего центроида без порога, теперь у них есть и собственный порог).
# Оставшиеся 10 взяты из `MOSCOW_OBLAST_CITY_ANCHORS` в
# packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py — те же
# координаты, что использует скрейпер (резолв Nominatim).
"Видное": (55.5519, 37.7133),
"Балашиха": (55.7969, 37.9386),
"Железнодорожный": (55.7444, 38.0128),
"Люберцы": (55.6767, 37.8931),
"Электросталь": (55.7845, 38.4449),
"Химки": (55.8894, 37.4450),
"Домодедово": (55.4368, 37.7681),
"Ногинск": (55.8554, 38.4412),
"Реутов": (55.7614, 37.8564),
"Долгопрудный": (55.9386, 37.5100),
"Красногорск": (55.8317, 37.3300),
"Одинцово": (55.6789, 37.2639),
"Подольск": (55.4312, 37.5450),
"Сергиев Посад": (56.3153, 38.1360),
"Королёв": (55.9142, 37.8256),
"Мытищи": (55.9116, 37.7308),
"Орехово-Зуево": (55.8062, 38.9810),
"Серпухов": (54.9155, 37.4196),
"Раменское": (55.5709, 38.2282),
"Наро-Фоминск": (55.3843, 36.7235),
"Щёлково": (55.9206, 37.9916),
"Коломна": (55.0939, 38.7670),
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
# Как сетка получена, почему точек именно столько, где проходит граница
# с областью и какова остаточная цена — см. большой комментарий над
@ -3072,30 +3227,18 @@ _COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Поварово": (56.0667, 37.0667),
"Дедовск": (55.8672, 37.1200),
"Нахабино": (55.8500, 37.1833),
"Реутов": (55.7614, 37.8564),
"Подольск": (55.4312, 37.5450),
"Апрелевка": (55.5500, 37.0700),
"Немчиновка": (55.7050, 37.3450),
"Химки": (55.8894, 37.4450),
"Одинцово": (55.6789, 37.2639),
"Лобня": (56.0100, 37.4750),
"Мытищи": (55.9116, 37.7308),
"Котельники": (55.6553, 37.8619),
"Красногорск": (55.8317, 37.3300),
"Люберцы": (55.6767, 37.8931),
"Дзержинский": (55.6294, 37.8500),
"Развилка": (55.5842, 37.7392),
"Климовск": (55.3667, 37.5333),
"Балашиха": (55.7969, 37.9386),
"Истра": (55.9167, 36.8667),
"Долгопрудный": (55.9386, 37.5100),
"Королёв": (55.9142, 37.8256),
"Видное": (55.5519, 37.7133),
"Селятино": (55.5081, 36.9825),
"Томилино": (55.6528, 37.9472),
"Некрасовский": (56.0500, 37.5500),
"Барвиха": (55.7333, 37.2333),
"Железнодорожный": (55.7444, 38.0128),
}

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

@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп
# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py:
# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light.
# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось.
CORRIDOR_CLAMP_MIN_N = 10
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -378,36 +384,25 @@ class Settings(BaseSettings):
dadata_api_token: str | None = None
dadata_api_secret: str | None = None
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
# При отсутствии IMV/Yandex no-op (медиана не меняется).
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
# данные ЭТОГО адреса, а не на среднее по городу.
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
# объявления. Число приходило из ДКП-коридора Росреестра, а тот при тонкой
# улице (n < DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и
# помечает себя scope="city_wide". Проверено на проде: у Янтарной ОДНА сделка,
# по Красногорску 4770 с медианой 200 676 ₽/м² — пользователю показали среднюю
# цену города как оценку его квартиры. Доля таких ответов 3.8% по ЕКБ, 4.8% по
# 77/50, то есть дефект продукта, а не регионального покрытия.
# Флаг делает две вещи, обе откатываются одним переключателем:
# 1) общегородской коридор больше не может стать headline (уличный — может:
# Нижний Тагил с 12 сделками по улице это настоящий сигнал, ради него
# #oblast-D и делали, иначе туда протекал екатеринбургский asking вшестеро
# выше);
# 2) если headline так и не появился, ответ уходит честной карточкой
# «недостаточно данных», а не нулями.
# False ⇒ прежнее поведение целиком, без релиза.
estimate_require_local_evidence: bool = True
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
# и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
estimate_sb_floor_sigma: float = 0.25
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
@ -417,6 +412,24 @@ class Settings(BaseSettings):
# (проверено: 80.0% coverage на тех же 2366).
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
# ── региональные множители PI (замер 2026-09-16) ──────────────────────────
# Скаляры выше сняты ТОЛЬКО на Екатеринбурге (обл.66, #1966) и молча
# применялись ко всем регионам — прогон `--engine full --sample 2000
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id
# --pi-report` (гедоническая коррекция #2002 уже выключена в main) показал,
# что достигнутое покрытие «80%-й» вилки уезжает по региону:
# регион n p10 p50 p90 факт. покрытие
# 77 1969 0.448 1.043 1.440 71.2%
# 50 1632 0.703 1.117 1.500 74.8%
# 66 1360 0.649 1.143 1.584 66.2%
# Воспроизведено на независимой выборке (seed 777): 77 → 0.453/1.040/1.455,
# 50 → 0.701/1.115/1.514 — числа устойчивы, это не шум сэмпла.
# ВАЖНО: p50 (медиана actual_sold/expected_sold) > 1 во ВСЕХ трёх регионах —
# точка expected_sold систематически занижена на 4-14%. Эта правка чинит
# только ШИРИНУ/покрытие вилки по региону, а НЕ смещение точки. Калибровка
# asking→sold на более коротком (свежем) окне — отдельная незакрытая задача.
estimate_pi_low_mult_by_region: dict[int, float] = {77: 0.448, 50: 0.703, 66: 0.649}
estimate_pi_high_mult_by_region: dict[int, float] = {77: 1.440, 50: 1.500, 66: 1.584}
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
# Диагноз: estimator систематически промахивается по эре дома + размеру —
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
@ -430,7 +443,27 @@ class Settings(BaseSettings):
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
# OFF ⇒ точно старое поведение expected_sold.
estimate_hedonic_correction_enabled: bool = True
# ВЫКЛЮЧЕНА 2026-09-16. Замер на проде показал, что коррекция превратилась в
# чистое занижение цены: она двигает точку вниз и НЕ покупает за это точности.
# Контролируемый эксперимент — одна и та же выборка (--engine full --sample 2000
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id), меняется
# ровно эта переменная:
# регион ВКЛ bias/MAPE/покрытие ВЫКЛ bias/MAPE/покрытие
# 77 Москва -9.1 / 20.5 / 69.0 -4.1 / 19.7 / 71.2
# 50 область -12.1 / 19.4 / 75.4 -10.4 / 19.2 / 74.8
# 66 обл. целиком -17.1 / 23.0 / 66.7 -12.4 / 23.2 / 66.2
# 66 только ЕКБ -17.4 / 21.2 / 69.8 -14.2 / 21.3 / 69.9
# Последняя строка решающая: ЕКБ — ровно та выборка, на которой коэффициенты
# перефитили 30.08.2026 (#3248, n=1269). Даже там выключение снимает 3.2 пункта
# занижения при неизменных MAPE и покрытии. Механика понятна из самих
# коэффициентов: b_larea=0, поэтому фактор = exp(-0.0140 + 0.0769*(year-2000)/20
# - 0.0745*[floor==1]), и для дома 1980 года это 0.913, то есть -8.7%. Жилой фонд
# в выборке преимущественно старый → фактор systematically < 1 → минус к цене.
# Коэффициенты фитили на остатке log(actual_sold/expected_sold) ПРИ ТОЙ базе,
# какая была тогда; база с тех пор уехала (area-бакетный ratio #2620, коэффициент
# asking->sold пересчитан по регионам 15.09), а хедонику не пересчитывали.
# ВКЛЮЧАТЬ ОБРАТНО только вместе с перефитом и замером всех четырёх строк выше.
estimate_hedonic_correction_enabled: bool = False
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
#
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
@ -464,128 +497,13 @@ class Settings(BaseSettings):
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
estimate_hedonic_factor_min: float = 0.75
estimate_hedonic_factor_max: float = 1.30
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
#
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
estimate_corridor_clamp_min_n: int = 10
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
estimate_corridor_clamp_slack: float = 0.40
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
# (избегаем ложных подъёмов) и ловит явный undershoot.
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_factor: float = 0.8
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
# к point И range (expected_sold_low/high/price) консистентно.
# False -> старое поведение без clamp (backward-compat).
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
estimate_expected_sold_le_asking: bool = Field(
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
)
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
estimate_sb_mad_k_small_n: float = 2.5
estimate_sb_small_n_threshold: int = 10
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
estimate_anchor_tier_c_corridor_mult: float = 1.5
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
estimate_outlier_small_n_threshold: int = 15
estimate_outlier_tukey_k_small: float = 1.0
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
# ratio — не затрагиваются.
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
estimate_sb_gate_min_n: int = 3
estimate_sb_gate_max_fsd: float = 0.20
# ── #audit-3: price_trend freshness filter ────────────────────────────────
# Исключать items старше N месяцев из price_trend (house_placement_history).
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
# аналога → headline выглядит достоверным при нулевой реальной базе.
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
estimate_manual_review_elite_ppm2: int = 250000
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
# (freshest scraped_at на (source, source_id|source_url|ctid)).
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
# building (building_cadastral_number | нормализованный address)
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
# («лот считается один раз»).
#
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
# 13.89%, coverage 83.33% → 83.33%, bias 3.83% → 3.83%, median width 0.743 →
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%, median width/cv
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
estimate_dedup_analogs_enabled: bool = True
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
@ -654,6 +572,16 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
estimate_unknown_attr_penalty_enabled: bool = False
estimate_unknown_attr_penalty_min_n: int = 5
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
# Ручки временные: после решения по бэктесту (#3234, шаг 4) вернуть их в
# константы движка, как #1970 сделал с остальными неменявшимися estimate_*.
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
estimate_tier_h_min_comps: int = 5
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
#
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
@ -668,20 +596,6 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
estimate_is_apartments_filter_enabled: bool = False
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
# растягивается через несколько ценовых режимов → коридор range_low/high
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range.
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
estimate_wide_corridor_threshold: float = 1.2
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
@ -706,26 +620,7 @@ class Settings(BaseSettings):
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
avito_imv_use_browser_fetcher: bool = False
# ── #764: per-cadastral-quarter price index correction ───────────────────
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
estimate_quarter_index_min_n_deals: int = 10
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
# аналоги уже несут локацию квартала → skip (location in median).
estimate_quarter_match_skip_ratio: float = 0.6
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
estimate_quarter_index_max_for_small_n: float = 2.0
estimate_quarter_index_small_n_threshold: int = 50
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
@ -904,6 +799,27 @@ class Settings(BaseSettings):
# ENV: ASKING_RATIO_PPM2_MAX.
asking_ratio_ppm2_max: int = 1_200_000
# Time-adjust для SOLD-стороны asking→sold ratio (rollback-флаг, без релиза).
# ПРОБЛЕМА: числитель ratio (deals, trailing 12 months) — сырые цены сделок,
# знаменатель (listings, LISTINGS_FRESH_DAYS) — сегодняшний рынок. На растущем
# рынке медиана сделок системно отстаёт от медианы объявлений, т.е. ratio занижен
# ровно на дрейф рынка за половину окна. Прод-замер 2026-09-16 (окно сделок
# 2025-10..2026-04, ряд real_estate_deals своего региона, clamp 0.7/1.6):
# регион 77 n=68 731 sold_median 323 529 → 360 330 (×1.1137, factor 1.077..1.147)
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
# При True каждая сделка домножается на sber-фактор приведения (тот же
# ряд/карту, что estimator._SBER_REGION_SERIES, см. app.tasks.asking_to_sold_ratio)
# к последнему доступному месяцу серии перед расчётом медианы. False — байт-в-байт
# прежнее поведение (для быстрого отката без деплоя, сравнения до/после).
# ENV: ASKING_RATIO_TIME_ADJUST_ENABLED.
asking_ratio_time_adjust_enabled: bool = True
# SSRF-защита для admin scrape endpoints (#756).
# Список хостов которым разрешено передавать абсолютные URL в параметрах *_url.
# Относительные пути (без netloc) проходят без проверки — хост подставляется
@ -1116,14 +1032,6 @@ class Settings(BaseSettings):
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
# ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
@ -1143,25 +1051,19 @@ class Settings(BaseSettings):
cian_login_success_cookie: str = "DMIR_AUTH"
cian_login_wait_ms: int = 4000
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
# 1 коннект/запрос) развязывает прокси-аккаунты.
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
# True = curl_cffi через settings.scraper_proxy_url.
#
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
# Прод переведён на браузер через docker-compose.prod.yml (environment
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
# других окружений вслепую.
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
# environment'ом сервиса scraper, а tradein-backend (ручка админки
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
avito_detail_backfill_use_curl: bool = Field(
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
)
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
@ -1287,6 +1189,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)). Флаг на УРОВНЕ ДВИЖКА кроет все
@ -65,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.
# Общий на приложение 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

@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи.
"""
@ -158,10 +158,18 @@ 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 >= CORRIDOR_CLAMP_MIN_N
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
из коридора, а поле обязано быть верным и на GET-rehydrate.
"""
count: int # число ДКП-сделок в выборке
@ -183,6 +191,33 @@ class DkpCorridor(BaseModel):
# None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None
@computed_field # type: ignore[prop-decorator]
@property
def advisory_only(self) -> bool:
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=1..9 на экране была неотличима от работающей.
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
обе страховки выключены якорем, а не порогом (#3466).
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.
Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а
не про то, что цену коридор не трогал.
Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом.
"""
from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light
return self.count < CORRIDOR_CLAMP_MIN_N
class PriceTrendPoint(BaseModel):
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
@ -195,6 +230,21 @@ class PriceTrendPoint(BaseModel):
ppm2: int # медиана ₽/м² за месяц
class ExposureWindow(BaseModel):
"""#2898: сколько дней похожие квартиры рядом висели до снятия объявления.
Квартили `house_placement_history.exposure_days`: те же комнаты, площадь ±15%,
дома в радиусе подбора аналогов, снятые за последние 24 мес. Это срок ДО СНЯТИЯ
объявления, а не подтверждённой продажи: в данных снятие и продажа неразличимы.
Окно либо целиком, либо None (выборка < `EXPOSURE_WINDOW_MIN_N`).
"""
p25_days: int
p50_days: int
p75_days: int
n: int # сколько снятых объявлений в выборке
class AggregatedEstimate(BaseModel):
estimate_id: UUID
median_price_rub: int
@ -257,7 +307,11 @@ class AggregatedEstimate(BaseModel):
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов
last_scraped_at: datetime | None = None
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
# #2898: окно срока экспозиции (p25p75) — показывать его, а не одно число.
exposure_window: ExposureWindow | None = None
# Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше —
# медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка).
est_days_on_market: int | None = None
cian_valuation: CianValuationSummary | None = None
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
@ -316,6 +370,9 @@ class AggregatedEstimate(BaseModel):
# "city" — radius Tier W: широкий fallback
# null — нет данных / оценка не построена
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
# #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius)
# восстанавливаются из подписи якорного блока в confidence_explanation
# (estimator.analog_tier_from_explanation), радиусные остаются null.
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
# search_radius_m — фактический радиус (метры), по которому реально отбирались
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:

View file

@ -67,8 +67,12 @@ class BlockRatioBreaker:
ratio_threshold: float
safety_min: int
snapshot_size: int
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
# площадки, рвут прогон. 0 — критерий выключен.
infra_streak_limit: int = 0
_window: deque[bool] = field(init=False, repr=False)
_consecutive_blocks: int = field(default=0, init=False)
_consecutive_infra: int = field(default=0, init=False)
_pure_block_run: bool = field(default=True, init=False)
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
@ -106,10 +110,16 @@ class BlockRatioBreaker:
короче окна рвал бы прогон по тем же infra-отказам, только другим
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
не считают.
Свой предохранитель у такого отказа серия infra_streak_limit (#3439): без
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
1600, status='done'). Серию гасит любой ответ площадки успех, её блок, 404.
"""
if kind != BAN_KIND_PLATFORM:
self._consecutive_infra += 1
self.record_failure()
return
self._consecutive_infra = 0
self._consecutive_blocks += 1
self._window.append(True)
@ -117,6 +127,7 @@ class BlockRatioBreaker:
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
self._flush_streak()
self._consecutive_infra = 0
self._pure_block_run = False
self._window.append(False)
@ -131,8 +142,11 @@ class BlockRatioBreaker:
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
задачи -- своя калибровка, свои ограничения прокси-пула)."""
return
задачи -- своя калибровка, свои ограничения прокси-пула).
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
значит наш тракт жив."""
self._consecutive_infra = 0
def _flush_streak(self) -> None:
if self._consecutive_blocks:
@ -142,9 +156,11 @@ class BlockRatioBreaker:
def abort_reason(self) -> str | None:
"""Какой критерий требует обрыва прямо сейчас, или None.
Возвращает "safety_net" / "ratio" / None. Состояние не меняет, поэтому
вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
Возвращает "infra_streak" / "safety_net" / "ratio" / None. Состояние не меняет,
поэтому вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
"""
if self.infra_streak_limit > 0 and self._consecutive_infra >= self.infra_streak_limit:
return "infra_streak"
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
@ -176,6 +192,11 @@ class BlockRatioBreaker:
should_abort(), так что в логи не попадает.
"""
reason = self.abort_reason()
if reason == "infra_streak":
return (
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
"без единого ответа площадки"
)
if reason == "ratio":
return (
f"доля блоков {self.window_blocks}/{self.window_len} в окне "

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 []

File diff suppressed because it is too large Load diff

View file

@ -46,6 +46,8 @@ from lxml import etree
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.regions import REGIONS
logger = logging.getLogger(__name__)
# Глобы файлов ГАР (матчим case-insensitive: .XML/.xml).
@ -520,6 +522,62 @@ def upsert_gar_houses(
return upserted
# ─────────────────────────────────────────────────────────────────────────────
# Город-фильтр матча: выбирается ПО РЕГИОНУ, а не задаётся руками на каждый запуск
# ─────────────────────────────────────────────────────────────────────────────
# Раньше был жёсткой CLI-константой "Екатеринбург" (годилось только для region 66).
# Регионы принципиально разные по форме коллизии одноимённых улиц:
# - 66 (область): ОДИН доминирующий город в инвентаре (истор. запуск продукта был
# ЕКБ-only) + >20 сопоставимых по названиям городов-соседей в REGIONS[66].cities —
# без фильтра «Машиностроителей 6» из 4 городов сливается в одну строку. Фильтр
# нужен и достаточен.
# - 77 (Москва): город ровно один — фильтровать нечем и незачем (см. REGIONS[77]).
# - 50 (область): НЕТ доминирующего города (20 сопоставимых по объёму городов-спутников,
# см. docstring REGIONS[50]) — фильтр по ОДНОМУ городу был бы не защитой, а порчей
# знаменателя (отрежет почти весь регион). Риск коллизии одноимённых улиц РАЗНЫХ
# городов при отсутствии фильтра закрыт не им, а guard'ом на стороне SQL
# (см. _MATCH_SQL: canon_hits) — неоднозначный canon не матчится вовсе, а не
# угадывается по max(flat_count).
# REGIONS_REQUIRING_CITY_FILTER — явный, единственный источник этого продуктового
# решения (какие регионы НУЖДАЮТСЯ в one-city ограничении); САМО значение фильтра
# берётся из REGIONS[<code>].city_token (реестр regions.py, ЕДИНСТВЕННОЕ место границ
# покрытия) — не второй раз хардкодится строкой "Екатеринбург".
REGIONS_REQUIRING_CITY_FILTER: frozenset[int] = frozenset({66})
class CityFilterAutoType:
"""Маркер «city_filter не передан явно» — резолвится по региону в match_houses_to_gar."""
__slots__ = ()
def __repr__(self) -> str:
return "CITY_FILTER_AUTO"
# Сентинел default'а (не None — None остаётся легитимным явным «фильтр отключён»).
CITY_FILTER_AUTO = CityFilterAutoType()
def default_city_filter_for_region(region_code: str | None) -> str | None:
"""Город-фильтр GAR-матча по умолчанию для региона (см. REGIONS_REQUIRING_CITY_FILTER).
region 66 "Екатеринбург" (byte-for-byte прежнее поведение, значение из
REGIONS[66].city_token). Любой другой/неизвестный/отсутствующий регион None
(без ограничения). region_code принимает и None, и нечисловую строку не
ошибка, просто «не знаем региона» без фильтра.
"""
try:
code = int(region_code) if region_code is not None else None
except (TypeError, ValueError):
return None
if code is None or code not in REGIONS_REQUIRING_CITY_FILTER:
return None
region = REGIONS.get(code)
if region is None:
return None
return region.city_token.capitalize()
# ─────────────────────────────────────────────────────────────────────────────
# Матчер ГАР → houses
# ─────────────────────────────────────────────────────────────────────────────
@ -529,17 +587,33 @@ def upsert_gar_houses(
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
#
# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) —
# На каждый canon берём ОДНУ ГАР-строку (ROW_NUMBER … ORDER BY flat_count DESC, house_guid) —
# детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически
# меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же
# канон, что и в gar_pick. ЕКБ-ограничение (:city ILIKE по full_address) обязательно: без него
# «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат
# `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки.
# канон, что и в gar_pick. С city-фильтром (:city IS NOT NULL, region 66) поведение
# byte-for-byte прежнее — ILIKE по full_address обязателен: без него «Машиностроителей 6» в
# 4 городах region 66 даёт ложные коллизии.
#
# БЕЗ city-фильтра (:city IS NULL — многогородские регионы без одного доминирующего города,
# напр. область 50) canon НЕ несёт населённый пункт (tradein_canon_addr режет всё, кроме улицы
# и номера дома — см. мигр. 144) — «Ленина 5» существует в десятках городов области. Молча
# брать «лучший по flat_count» здесь означало бы РАНДОМНО пришить дом одного города к дому
# другого. Вместо этого canon_hits (COUNT(*) OVER PARTITION BY canon в рамках уже
# region/city-отфильтрованной выборки) — если у canon >1 разных GAR-домов, ambiguity
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
# повторный прогон не трогает уже совпавшие строки.
#
# #2583 H5: фильтры выше стоят только на стороне ГАР, а UPDATE houses шёл по одному канону
# по ВСЕМ домам всех регионов. После #3523 (матч для 77/50) прогон региона 50 проставил
# подмосковные guid екатеринбургским домам с тем же каноном (прод 2026-09-17: 578 домов
# региона 66 с guid региона 50, 340 — региона 77, 1371 дом 50 с guid 77, 497 домов 77 с
# guid 50). Дом получает guid только своего региона; канон ранжируется внутри региона.
_MATCH_SQL = text(
"""
WITH gar_pick AS (
SELECT DISTINCT ON (canon)
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
WITH gar_scope AS (
SELECT
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count, region_code
FROM gar_house_flats
WHERE flat_count > 0
AND tradein_canon_addr(norm_address) <> ''
@ -551,7 +625,21 @@ _MATCH_SQL = text(
CAST(:city AS text) IS NULL
OR full_address ILIKE '%' || CAST(:city AS text) || '%'
)
ORDER BY canon, flat_count DESC, house_guid
),
gar_ranked AS (
SELECT
canon, house_guid, flat_count, region_code,
ROW_NUMBER() OVER (
PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid
) AS rn,
COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits
FROM gar_scope
),
gar_pick AS (
SELECT canon, house_guid, flat_count, region_code
FROM gar_ranked
WHERE rn = 1
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
)
UPDATE houses h
SET gar_house_guid = gp.house_guid,
@ -562,29 +650,59 @@ _MATCH_SQL = text(
WHERE tradein_canon_addr(
COALESCE(h.short_address, h.full_address, h.address)
) = gp.canon
AND CAST(h.region_code AS text) = gp.region_code
AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
"""
)
# #2583 H5: снять уже проставленные guid чужого региона (см. коммент у _MATCH_SQL). Сам
# _MATCH_SQL их не перезапишет, если у дома нет пары в своём регионе, — а по чужому guid
# ЖКХ/ФРТ/капремонт-загрузчики продолжат тянуть данные другого дома. Только canon_addr и
# только доказуемо чужие (регион дома известен и не совпадает) — дом без региона не трогаем.
_CLEAR_FOREIGN_REGION_SQL = text(
"""
UPDATE houses h
SET gar_house_guid = NULL,
gar_flat_count = NULL,
gar_matched_at = NULL,
gar_match_method = NULL
FROM gar_house_flats g
WHERE g.house_guid = h.gar_house_guid
AND h.gar_match_method = 'canon_addr'
AND CAST(h.region_code AS text) <> g.region_code
AND (
CAST(:region AS text) IS NULL
OR CAST(h.region_code AS text) = CAST(:region AS text)
)
"""
)
def match_houses_to_gar(
db: Session,
*,
region_code: str | None = None,
city_filter: str | None = "Екатеринбург",
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
) -> int:
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE
защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
за пределами ЕКБ future work). Возвращает число обновлённых домов. Идемпотентно
(plain UPDATE, IS DISTINCT FROM gate).
city_filter: CITY_FILTER_AUTO (умолч.) резолвится по region_code через
default_city_filter_for_region (region 66 «Екатеринбург», иначе None). Явный
None отключает фильтр НЕЗАВИСИМО от региона; явная строка ILIKE-override
(любой регион). Без фильтра ambiguity одноимённых улиц разных городов закрыта
отдельно см. _MATCH_SQL (canon_hits). Возвращает число обновлённых домов.
Идемпотентно (plain UPDATE, IS DISTINCT FROM gate).
"""
if isinstance(city_filter, CityFilterAutoType):
city_filter = default_city_filter_for_region(region_code)
cleared = db.execute(_CLEAR_FOREIGN_REGION_SQL, {"region": region_code}).rowcount
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
matched = result.rowcount
logger.info(
"gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)",
"gar match: проставлено gar_flat_count домам=%d, снято guid чужого региона=%d"
" (region=%s, city=%s)",
matched,
cleared,
region_code,
city_filter,
)

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
@ -36,7 +37,11 @@ _REGION_66 = _ALL_REGIONS[66]
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
# без записи здесь получают `marker=None` → cross-check пропускается
# (fallback на bbox-only, прежнее поведение).
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
# 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская
# область"` для области и `state="Москва"` для города. Маркер "московская" не
# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который
# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут.
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"}
logger = logging.getLogger(__name__)
@ -199,9 +204,10 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
Словарь `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
(`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не
резолвит) т.е. новый город области в любом случае добавляется СЮДА, и
гейт хинта не создаёт новой связности.
(`_names_non_ekb_city` его не увидит) т.е. новый город области в любом
случае добавляется СЮДА, и гейт хинта не создаёт новой связности.
(`estimator._resolve_target_city` дополнительно знает города развёрток,
#2583 H1 — ему город нужен для фильтра сделок, а не для гейта.)
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
@ -961,7 +967,12 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
_DADATA_REGION_NAMES: dict[int, str] = {
66: SVERDLOVSK_OBLAST_REGION,
77: "Москва",
# Без типа — DaData хранит `region="Московская"`, `region_type="обл"`.
50: "Московская",
}
def _dadata_region_name(region_code: int) -> str:
@ -1105,7 +1116,14 @@ async def _nominatim_query_city_aware(
)
if city_specified:
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Город неизвестен — dual-query с суффиксом главного города региона
# Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`,
# реестр регионов; на сегодня это 50 — Московская область) суффикс главного
# города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово,
# и суффикс уверенно притянет чужой город — ровно ловушка #2576, только
# уровнем выше. Такому региону остаётся честный bare-запрос по viewbox.
if not _ALL_REGIONS[region_code].has_city_core:
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Дальше — регион с ядром: dual-query с суффиксом главного города
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
default_city = _region_default_city(region_code)
city_data = await _nominatim_query_multi(
@ -1884,11 +1902,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 +2013,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 +2046,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 +2059,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 +2074,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 +2084,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 +2095,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 +2106,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 +2117,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 +2136,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 +2345,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

@ -25,14 +25,17 @@ WHAT this is:
«улица Вайнера, 66» share one cluster_key. Rows whose canon is NULL/blank are never clustered
(cluster_key NULL ignored). Only canons shared by >1 house_id form a cluster.
GEO GUARD (anti over-merge, CANON PASS ONLY #2187): because the canon strips город/район,
GEO GUARD (anti over-merge, BOTH PASSES #2690): because the canon strips город/район,
two different buildings with the same street+number in different region-66 towns (e.g. «Ленина
5») would share a canon. To avoid merging them, within a canon-cluster a house is a LOSER only
if it is within 250 m of the keeper (ST_DistanceSphere on the WGS84 geom). Same-canon houses
>250 m away or with NULL geom on either side are left as separate rows (conservative). The
FIAS pass deliberately SKIPS this guard: a shared ФИАС/ГАР UUID IS the building identity and
strictly outranks geo-proximity, so same-fias rows merge even with NULL geom on a side or
>250 m apart (the geom-first keeper rule simultaneously repairs the broken coordinate).
5») would share a canon. To avoid merging them, within a cluster a house is a LOSER only
if it is within 250 m of the keeper (ST_DistanceSphere on the WGS84 geom). Same-key houses
>250 m away or with NULL geom on either side are left as separate rows (conservative).
The FIAS pass used to skip this guard (#2187, «a shared ФИАС UUID IS the building identity»).
That premise is false: house_fias_id is DaData's answer to OUR address string (KEY section
below), so a street+number without a town gets the same UUID as the same street+number in
another town. Prod run 2026-09-05 (house_merge_log, merge_pass='fias'): 408 merges, 129 of
them >250 m apart, 53 >3 km, worst 362 km («р-н Синарский, улица Кирова, 4» of
Каменск-Уральский into Екатеринбург), plus 67 with no coordinates on a side.
TWO PASSES (2026-07-02 follow-up): the SAME clusterkeeperre-pointcarry-identitydelete
pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression
@ -45,10 +48,8 @@ TWO PASSES (2026-07-02 follow-up): the SAME cluster→keeper→re-point→carry-
2. CANON pass the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a
canon cluster a loser is NOT merged when it AND the keeper both carry a non-null but
DIFFERENT house_fias_id provably different buildings the canon collapsed (the
slash-collapse class). Critical anti-over-merge fix. The 250 m geo guard applies to the
CANON pass ONLY (#2187): the FIAS pass merges on UUID identity regardless of geom — a
боевой прогон left 130 same-fias groups (335 houses, 1 529 listings) split because the
guard blocked them (NULL geom on a side, or >250 m from a broken coordinate).
slash-collapse class). Critical anti-over-merge fix. The 250 m geo guard applies to
BOTH passes (#2690; the fias-pass exemption of #2187 is reverted — see GEO GUARD).
IDENTITY CARRY-OVER (each pass, BEFORE deleting losers): the keeper's NULL identity / geo-QC
fields are filled from its losers with COALESCE semantics (keeper value wins; donor = the
@ -114,9 +115,9 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
keeper rule and no guard. It only makes whatever the pass decides reversible which is the
precondition for revisiting those decisions at all (#2690, #1772).
distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That
asymmetry merge allowed without a proximity check was invisible in data before; now
«how many merges happened beyond N metres, on which key» is one query.
distance_m is recorded on BOTH passes. It is what exposed the fias pass merging without a
proximity check (#2690, 2026-09-05): «how many merges happened beyond N metres, on which key»
is one query.
KEY there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
#2690 asked for a cluster key that does not come from the normalized address, so that two
@ -311,35 +312,15 @@ def _ranked_cte(cluster_key_case: str) -> str:
)"""
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
def _mapping_sql(cluster_key_case: str) -> str:
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
row shares one fias by construction).
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
- CANON pass True: the canon strips город/район, so same-street-number buildings in
different region-66 towns share a canon; the guard stops the cross-town over-merge.
- FIAS pass False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
geom-first keeper rule simultaneously repairs the broken coordinate).
row shares one fias by construction). So does the 250 m GEO GUARD on BOTH passes (#2690):
neither key is independent of our address string, so neither may merge what proximity rejects.
`cluster_key_case` is a STATIC module constant (never runtime data) no value injection.
"""
geo_guard = (
"""
-- GEO GUARD (canon pass only #2187). tradein_canon_addr strips город/район, so two
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
-- keeper (<=250 m covers one building's geocode spread, prod: Мраморская 34к4 dupes at
-- 222 m; region-66 towns are km+ apart 250 m is safe from cross-town). >250 m, or NULL
-- geom on either side, left as separate rows (conservative never over-merges).
AND keeper_geom IS NOT NULL
AND loser_geom IS NOT NULL
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
if apply_geo_guard
else ""
)
return f"""
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
{_ranked_cte(cluster_key_case)}
@ -350,16 +331,22 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
--
-- cluster_key / distance_m are carried out of the mapping for the MERGE JOURNAL (#2690):
-- cluster_key records WHICH key value fired, distance_m how far apart the two rows were.
-- distance_m is computed even when the geo guard is OFF for this pass that is precisely
-- the case where nothing else records the distance, and #2690 had no way to ask
-- «how many merges happened at distances the guard would have blocked» from data.
-- distance_m is what showed the fias pass merging 362 km apart while it had no guard (#2690).
SELECT id AS loser_id, keeper_id, norm_address, cluster_key,
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
THEN ST_DistanceSphere(loser_geom, keeper_geom)
END AS distance_m
FROM ranked
WHERE rn > 1
AND id <> keeper_id{geo_guard}
AND id <> keeper_id
-- GEO GUARD (both passes #2690). Neither cluster key tells towns apart: the canon strips
-- город/район, and house_fias_id is DaData's answer to that same town-less address. A loser
-- merges only when geographically next to the keeper (<=250 m covers one building's
-- geocode spread, prod: Мраморская 34к4 dupes at 222 m; region-66 towns are km+ apart).
-- >250 m, or NULL geom on either side, left as separate rows (conservative).
AND keeper_geom IS NOT NULL
AND loser_geom IS NOT NULL
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250
AND NOT (
NULLIF(loser_fias, '') IS NOT NULL
AND NULLIF(keeper_fias, '') IS NOT NULL
@ -368,12 +355,10 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
"""
# Canon-pass mapping — geo guard ON (cross-town over-merge protection for street+number canons).
# Canon-pass mapping.
_BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
# Fias-pass mapping — same pipeline, clustered by the ФИАС building UUID (runs first). Geo guard
# OFF (#2187): a shared ГАР UUID IS the building identity and outranks proximity — same-fias rows
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
# Fias-pass mapping — same pipeline and same guards, clustered by the ФИАС UUID (runs first).
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR))
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
#
@ -870,7 +855,6 @@ def _run_merge_pass(
*,
build_sql: Any,
pass_label: str,
geo_guard: bool,
batch_id: str,
run_id: int | None,
initiator: str,
@ -911,7 +895,7 @@ def _run_merge_pass(
"run_id": run_id,
"initiator": initiator,
"merge_pass": pass_label,
"geo_guard": geo_guard,
"geo_guard": True, # both passes are guarded since #2690
},
)
@ -1032,8 +1016,8 @@ def merge_duplicate_houses(
Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job:
1. FIAS pass cluster by lower(NULLIF(house_fias_id, '')) (catches slash-collapse /
посёлок canon bugs the address canon misses).
2. CANON pass cluster by canonical address (250 m geo guard + cross-fias anti-over-merge
guard).
2. CANON pass cluster by canonical address.
Both passes carry the 250 m geo guard and the cross-fias anti-over-merge guard.
Each pass: pick keeper re-point children (UNIQUE-collision-safe) carry identity onto the
keeper delete losers backfill sources/aliases. BOTH passes run in ONE transaction.
dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an
@ -1057,7 +1041,6 @@ def merge_duplicate_houses(
db,
build_sql=_BUILD_MAPPING_SQL_FIAS,
pass_label="fias",
geo_guard=False,
batch_id=batch_id,
run_id=run_id,
initiator=initiator,
@ -1068,7 +1051,6 @@ def merge_duplicate_houses(
db,
build_sql=_BUILD_MAPPING_SQL,
pass_label="canon",
geo_guard=True,
batch_id=batch_id,
run_id=run_id,
initiator=initiator,

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
@ -236,6 +239,12 @@ async def _job_deactivate_stale(
listing_source: str = params.get("listing_source", "avito")
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
segments: list[str] | None = params.get("segments")
# Регионы снятия. None (ключа нет в default_params) -> дефолт самой джобы,
# DEFAULT_DEACTIVATION_REGION_CODES=(66,): снимать устаревшее можно только там,
# где идёт регулярный пересбор -- см. развёрнутый комментарий у константы в
# deactivate_stale_avito.py. Расширять список ВМЕСТЕ с запуском сбора по
# региону, ключом "region_codes" (jsonb-массив кодов) в строке расписания.
region_codes: list[int] | None = params.get("region_codes")
staleness_column: str = params.get("staleness_column", "last_seen_at")
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
@ -275,6 +284,7 @@ async def _job_deactivate_stale(
listing_source=listing_source,
ttl_days=ttl_days,
segments=segments,
region_codes=region_codes,
staleness_column=staleness_column,
min_confirmations=min_confirmations,
revisit_floor_quantile=revisit_floor_quantile,
@ -325,6 +335,50 @@ 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)
)
# #3511: пустая витрина — не успех. Результатного ключа кита
# (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных
# судит её по статусу, и `done` с written=0 гасил часы свежести так же,
# как удачный прогон, — а страница тем временем теряла таблицу целиком.
if not counters.get("written"):
logger.error(
"landing_showcase_deals run_id=%d: витрина пуста (written=0, "
"considered=%s, eligible=%s) — прогон неуспешен",
run_id,
counters.get("considered"),
counters.get("eligible"),
)
ctx.runs.mark_failed(
db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters
)
else:
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 +541,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 +966,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"
@ -968,6 +1024,12 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
),
# Override kit-native (#3264) — инъекция куки сессии, см. докстринг job'а выше.
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
# Вне ЕКБ каждый регион получает СВОЙ source ("domclick_city_sweep_moskva"),
# потому что scrape_schedules.source UNIQUE. Wildcard обязан стоять и здесь,
# а не только в _default_kit_handlers: kit-native тело ходит БЕЗ кук сессии,
# и московская строка молча резолвилась бы на версию без инъекции (#3264),
# то есть на ту, что упирается в QRATOR. Ключ продукта перекрывает kit-native.
"domclick_city_sweep_*": Handler(_job_domclick_city_sweep, "domclick_city_sweep_*"),
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),

View file

@ -16,7 +16,8 @@ reap_stale_leases) — она рассчитана на долгоживущие
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
и без мутаций.
и без мутаций пула. Единственная запись атрибуция прогона (`scrape_runs.proxy_id`, #3404),
своей короткой сессией, см. `resolve_proxy_url`.
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now())
@ -69,7 +70,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings as _settings
from app.core.db import SessionLocal as _SessionLocal
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS, attribute_run_proxy
logger = logging.getLogger(__name__)
@ -277,6 +278,21 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
_safe_label(candidate.id, candidate.label, candidate.url),
candidate.ban_count,
)
# #3404: прогоны на этом пути (yandex_detail_backfill, yandex_address_backfill,
# curl-ветка avito_detail_backfill) lease не берут, а атрибуцию раньше писал только
# acquire() — у yandex_detail_backfill proxy_id был NULL в 56 прогонах из 56.
# Своя сессия, не db вызывающего: attribute_run_proxy коммитит, а на сбое
# откатывает, db же — долгоживущая сессия прогона посреди работы (см.
# _pick_candidate). Сбой атрибуции проглатывается внутри — выдачу не роняет.
from scraper_kit.orchestration.run_context import current_run_id
run_id = current_run_id.get()
if run_id is not None:
attr_db = _SessionLocal()
try:
attribute_run_proxy(attr_db, run_id, candidate.id)
finally:
attr_db.close()
return candidate.url
diag = _diagnose_no_candidate(db, source)
@ -302,7 +318,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

@ -275,12 +275,13 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
чужая только запасной вариант, чтобы источник не голодал при живых свободных узлах
чужой affinity (#2600).
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
Fallback НЕ трогает последний пригодный узел выделенной (не-'any') affinity: если
fallback заберёт его под чужой источник, «свой» останется без прокси вообще хуже,
чем голодание исходного источника, которое фикс призван устранить. Кандидат
участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
enabled-узел (EXISTS-подзапрос) т.е. выдача не обнулит доступность выделенной
affinity целиком.
участвует в fallback, только если его affinity='any' ИЛИ у источника этой affinity
без него останется ДРУГОЙ кандидат (EXISTS-подзапрос): узел той же affinity или
'any', здоровый, с живой арендой порта и не забаненный этим источником (#3299 —
до него 'any'-узлы не считались, и единственный выделенный узел не выдавался никому).
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
@ -401,17 +402,29 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
-- fallback увести последний реально рабочий узел выделенной
-- affinity и обрушить её (два domclick-узла, один забанен
-- domclick'ом → второй уходит под avito → domclick без прокси).
--
-- #3299: вопрос «останется ли у источника sp.provider_affinity
-- хоть один кандидат без sp», а не «есть ли ВТОРОЙ узел той же
-- привязки». 'any'-узлы основной запрос выдаёт выделенному
-- источнику наравне, значит и backup'ом они считаются; прежний
-- `= sp.provider_affinity` прятал единственный выделенный узел от
-- всех. Здоровье и срок аренды как в основном запросе.
-- leased_by НЕ проверяем: аренда вернётся через минуты, а
-- отказ из-за чужой аренды прятал бы узел при любом параллельном
-- прогоне (тот же довод, что у защиты в mark_banned).
OR EXISTS (
SELECT 1
FROM scrape_proxies AS other
WHERE other.provider_affinity = sp.provider_affinity
WHERE other.provider_affinity IN (sp.provider_affinity, 'any')
AND other.enabled
AND other.consecutive_fails < CAST(:max_fails AS integer)
AND (other.expires_at IS NULL OR other.expires_at > now())
AND other.id <> sp.id
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b2
WHERE b2.proxy_id = other.id
AND b2.source = other.provider_affinity
AND b2.source = sp.provider_affinity
AND b2.banned_until > now()
)
)
@ -446,9 +459,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
)
db.commit()
if run_id is not None and run_id != NON_RUN_LEASE_MARKER:
# #3404: одна точка, покрывающая ВСЕ пути выдачи (curl — acquire на каждый
# вызов, браузер — sticky lease на весь прогон, ре-acquire при ротации узла
# mid-run) — см. attribute_run_proxy docstring.
# #3404: покрывает все пути АРЕНДЫ (curl — acquire на каждый вызов, браузер —
# sticky lease на весь прогон, ре-acquire при ротации узла mid-run). Путь без
# аренды (proxy_egress.resolve_proxy_url) пишет атрибуцию сам — см. docstring.
attribute_run_proxy(db, run_id, proxy_id)
if fallback_used:
logger.warning(
@ -486,11 +499,12 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
"""Записать узел, через который идёт прогон run_id, в scrape_runs (#3404).
Единственный писатель `acquire()` сразу после выдачи lease'а: покрывает и
curl-путь (acquire на каждый вызов), и браузерный sticky lease (один acquire на
весь прогон), и ре-acquire при ротации узла mid-run (`browser_fetcher`,
`_LEASE_ROTATE_AFTER_FAILS`) то есть смена узла ЗА прогон фиксируется сама,
без отдельного вызова с чьей-либо стороны.
Два писателя. `acquire()` сразу после выдачи lease'а: покрывает и curl-путь
(acquire на каждый вызов), и браузерный sticky lease (один acquire на весь прогон),
и ре-acquire при ротации узла mid-run (`browser_fetcher`,
`_LEASE_ROTATE_AFTER_FAILS`) то есть смена узла ЗА прогон фиксируется сама.
И `proxy_egress.resolve_proxy_url` egress без аренды (yandex_detail_backfill и
др.); до этого у таких прогонов proxy_id оставался NULL (#3404, прод 17.09: 0 из 56).
`scrape_runs.proxy_id` ПОСЛЕДНИЙ использованный узел (перезаписывается при
каждой новой выдаче); полная цепочка узлов, если она менялась, в
@ -535,8 +549,7 @@ def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
db.commit()
if row is None:
logger.debug(
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already "
"finalized?)",
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already finalized?)",
run_id,
)
except Exception:
@ -983,6 +996,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
WHERE sp.id <> CAST(:proxy_id AS bigint)
AND sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
-- Как в acquire(): узел с истёкшей арендой порта остаётся enabled,
-- но не выдаётся. Без этой строки он «спасал» источник, и бан уходил
-- последнему живому узлу (ревью #3565 к #3299).
AND (sp.expires_at IS NULL OR sp.expires_at > now())
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
@ -999,17 +1016,22 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
-- fallback: enabled-узел, забаненный СВОИМ источником, backup'ом
-- не считается (иначе защита сочла бы affinity живой, когда она
-- уже нет).
-- #3299: предикат backup'а — буква в букву как в fallback
-- acquire() (там же обоснование): 'any'-узлы в счёт, здоровье
-- и срок аренды проверяются.
OR EXISTS (
SELECT 1
FROM scrape_proxies other
WHERE other.provider_affinity = sp.provider_affinity
WHERE other.provider_affinity IN (sp.provider_affinity, 'any')
AND other.enabled
AND other.consecutive_fails < CAST(:max_fails AS integer)
AND (other.expires_at IS NULL OR other.expires_at > now())
AND other.id <> sp.id
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b2
WHERE b2.proxy_id = other.id
AND b2.source = other.provider_affinity
AND b2.source = sp.provider_affinity
AND b2.banned_until > now()
)
)
@ -1309,6 +1331,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 +1352,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",

File diff suppressed because one or more lines are too long

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