Commit graph

3572 commits

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

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

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

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

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

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

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

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

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

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

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

Closes #3452

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Тесты

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Тесты

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-12 01:53:33 +03:00
a99a9b870c Merge pull request 'Москва: пред-геокод Авито, Яндекс третьей площадкой, продукт отвечает по региону 77' (#3440) from feat/msk-collector-cian into main
Some checks failed
Deploy Trade-In / test (push) Failing after 4m18s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
2026-09-11 22:30:11 +00:00
bot-backend
09f4f17ef9 fix(msk): lock_timeout в миграции 299 — гейт блокирующего DDL
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
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
Джоба `changes` воркфлоу ci.yml валила PR по собственному правилу репозитория
(#2752): блокирующий DDL без `SET LOCAL lock_timeout` встанет в очередь за чужой
сессией и уведёт за собой запросы приложения. Нарушение было в 299 и до правки
схемы — просто до гейта раньше не доходило.

Добавлена первая строка после BEGIN, как в 36 соседних миграциях. Повторно
проверено на пустой базе: применяется с ON_ERROR_STOP=1, пять таблиц на месте.

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

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

Refs #3256
2026-09-12 02:39:28 +05:00
4efcb712e4 Merge pull request 'fix(mera/estimate): коннект БД не живёт через внешний HTTP; потолок пула ≥ суммы потолков одновременности (#3083, #3408)' (#3444) from fix/3083-estimate-throughput into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m14s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 2m44s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-11 21:33:14 +00:00
bot-backend
7b84e4d2a5 fix(msk): миграция 299 заводит схему msk_raw сама, а не полагается на прод
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
CI Trade-In падал до единого теста: «schema "msk_raw" does not exist», job
backend-tests, run 10854. Схему и первые две таблицы (`batches`, `avito_cards`)
заводили на проде руками 08.09 — сбор сырья стартовал раньше модели данных, и
msk_raw сознательно жила вне линейки миграций. На чистой базе CI этого контекста
нет, а 299 сразу создаёт таблицы внутри схемы и вешает внешний ключ на
`msk_raw.batches`.

DDL продовских объектов повторён в 299 идемпотентно, определения сняты
`pg_dump -s -n msk_raw`, чтобы CI и прод не разъехались молча. На проде это
no-op. Проверено на пустой базе migtest_msk: применяется с ON_ERROR_STOP=1,
даёт все пять таблиц и четыре вью, повторное применение проходит без ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-12 00:27:09 +03:00
16d99e0f1a fix(estimator): убрать предикат по deals.rooms, а не подставлять в него area-бакет
Разворот предыдущего коммита ветки (a780e3e6) на корень: вместо подстановки
`area_bucket(area)` в предикат `d.rooms = ...` предикат УДАЛЁН во всех трёх местах.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #3083, #3408
2026-09-12 02:20:26 +05:00