Commit graph

677 commits

Author SHA1 Message Date
3d38d589d0 fix(tradein): панорама для страниц без истории, точные счётчики, окно без гонки (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
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 3m0s
Правки по ревью PR #2689.

Признак панорамы был недостижим примерно для десятой части страниц. Вызов стоял
после раннего возврата по пустой истории размещений, поэтому идеально отрисованная
страница без единого объявления до записи не доходила: на проде 1519 оценок против
1360 домов с историей. Резолв дома и запись панорамы подняты выше возврата — гейт
честности не тронут. Цена: match_or_create_house теперь вызывается и для таких
страниц (может создать дом), но это тот же вызов с тем же адресом, который уже
отрабатывает на остальных 90%.

Числа в комментариях к схеме были оценками планировщика, а не точным счётом:
listings 142 569 против реальных 93 408 (раздув мёртвыми кортежами на 53%),
house_sources 46 813 против 49 502. На безопасность удаления это не влияло — нули
там точные, — но оценка уезжала в постоянный комментарий к схеме, в PR, тезис
которого «каждое утверждение несёт число с прода». Пересчитано точным count(*).

Окно расписания ДОМ.РФ 03:00-04:00 совпадало с refresh_search_matview — то есть
ровно с тем заданием, которое переносит year_built в поиск. Планировщик берёт
случайный момент внутри окна и гоняет источники параллельно, так что порядок был
подбрасыванием монеты. Перенесено на 01:00-02:00; в комментарии честно сказано, что
гарантии всё равно нет и при аномально долгом прогоне возможно отставание на цикл.

Тесты, адресовавшие вызовы по позиции (db.execute.call_args_list[0]), переведены на
фильтр по SQL — это и была причина, по которой добавление второго execute ломало
шесть чужих тестов разом. То же для side_effect в тесте отката батча: исключение
доставалось бы записи панорамы, которая свои ошибки глотает, и тест молча проверял
бы не тот путь. В test_save_history_items_inserts_each возвращено утверждение о
числе коммитов (было удалено вместо обновления).

Refs #2674
2026-08-06 05:32:14 +05:00
f5b39e6fc9 chore(tradein): разбор мёртвого кода — подключить, удалить или задокументировать (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m57s
Восемь находок «написано, покрыто тестами, ни разу не сработало» разведены на три
разных диагноза. Две из восьми оказались не мёртвым кодом, а оборванной проводкой.

ПОДКЛЮЧЕНО

Загрузчик ДОМ.РФ. Loader и CLI существуют с #2013, а Handler'а в product_handlers
и строки в scrape_schedules не было — вызвать его было нечем. На проде 29 978 строк
staging с ОДНИМ loaded_at (2026-07-12), то есть ровно один ручной запуск, 24 дня
без обновления. Отсюда кормятся houses.year_built/material_walls/total_floors и
дальше listings.year_built — когортный фильтр эстиматора. Недельный такт, окно
03:00-04:00 UTC (до импорта ДКП и дневных агрегатов).

filters_hash. Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ
уровнем выше — estimation.sale.filtersHash. Колонка пуста 0/1658, при том что в
сохранённых сырых ответах хеш есть у 139/139 и все значения различны. Путь исправлен,
139 строк восстановлены бэкфиллом из raw_payload.

has_panorama. Разбирался парсером, лежал в карте приоритетов, обещан публичным
контрактом market.v_houses — и не попадал в houses ни одной строкой кода (0 из 9366).
Пишется там, где yandex_valuation уже держит и house_id, и мету. Гейт честности:
парсер отдаёт bool, а не bool|None, поэтому false пишем только при подтверждённо
отрисованной странице (есть год или этажность) — иначе NULL, а не выдуманный false.

УДАЛЕНО

Дедуп-обёртки эстиматора _phys_dedup_key / _extract_street_token: 25 ссылок, все из
тестов. Хуже, чем просто мёртвые — _phys_dedup_key утверждала правило «ключ = кадастр
ИЛИ улица», которого в боевом дедупе нет (_union_find_phys_dedup держит оба композита
и сливает по любому совпадению). Тесты переведены на живые функции.

Тиерные коэффициенты выкупа asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds:
ноль читателей и писателей, флага tier_aware_ratio_enabled не существует. Посчитаны
один раз при накатке 098 (computed_at 2026-06-27) — тогда как живая
asking_to_sold_ratios обновляется ежедневно (2026-08-05). Методика сохранена в 098.

Колонки без писателя: listings.merged_into (113 уже называла её мёртвой) и
house_sources.raw_payload вместе с GIN-индексом по всегда-NULL колонке.

v_data_quality.price_disagreements_count: у всех 89 699 объявлений ровно один
источник, показатель структурно не мог быть ненулевым, а ноль читался как
«расхождений нет».

ЗАДОКУМЕНТИРОВАНО

BROWSER_BLOCK_RESOURCES выставлен во всех трёх прод-контейнерах, а код перестал его
читать в #1812. Блокировка при этом не ослабла (image глушит camoufox block_images,
font/media — дефолт списка типов), мёртв только выключатель. Сервис теперь говорит
об этом на старте: молча игнорируемая ручка опаснее отсутствующей.

v_price_divergence / v_cross_source_health оставлены как задел, но в COMMENT написано,
почему они пусты структурно: боевой путь загрузки зовёт upsert_listing_source
напрямую и не зовёт match_or_create_listing.

house_sources.ext_url пуст 46 813/46 813, но входит в публичный контракт
market.v_house_sources — оставлен и подписан.

Refs #2674
2026-08-06 05:00:36 +05:00
3c5f535e6c fix(tradein/admin): гейт отмены по источнику, честный комментарий view, лимит 50 (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
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 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 2m58s
Ревью PR #2684 — четыре MINOR.

1. Починка фильтра открыла кнопку отмены на все 53 источника. Раньше таблица была
   пуста на каждой вкладке, поэтому кнопка не рендерилась НИ РАЗУ и дыра не
   проявлялась: ручки отмены source не проверяют вовсе. Оператор на вкладке Авито
   мог бы «отменить» refresh_search_matview — задача продолжила бы работать под
   статусом 'cancelled' (ещё один врущий статус ровно в тот день, когда их
   вычищаем), а has_running_run перестал бы держать single-run guard, который
   существует из-за инцидента с двойным свипом и баном (2026-05-31).

   Гейт поставлен на общем узле всех пяти ручек — scrape_runs.honors_cancel +
   отказ в mark_cancelled, — а не в UI: иначе ручной POST по-прежнему снимал бы
   guard. Флаг cancellable отдаётся в строке, UI по нему прячет кнопку.
   Состав набора выведен из call-site'ов runs.is_cancelled: city-sweep'ы (все
   площадки и города), full-load'ы, avito_newbuilding_sweep, rosreestr_dkp_import.
   Правило НЕ «любой *_sweep»: yandex_newbuilding_sweep отмену не опрашивает.

2. Комментарий пересозданного v_data_quality утверждал, что его обновляет
   /api/v1/admin/data-quality. Читателей у view нет ни одного — живая ручка строит
   свой запрос. PR с тезисом «ложный показатель хуже отсутствующего» не имеет права
   переносить в прод ложное утверждение о читателе.

3. Лимит выдачи 20 → 50: первые 20 строк по started_at на три четверти —
   сердцебиение proxy_healthcheck (1631 из 3245), часовой сбор мог не поместиться.
   Привязка к вкладке НЕ возвращается.

4. Тест «действующее определение view» искал маркер подстрокой с OR REPLACE —
   миграция с обычным CREATE VIEW или парой DROP+CREATE была бы невидима, и тест
   проверял бы 214, пока показатель уже вернулся. Заменено регуляркой на обе формы.

Фальсификация трёх новых тестов патч-методом — все три красные. Полный прогон
3490 passed / 9 skipped, tsc --noEmit чистый.
2026-08-06 03:57:46 +05:00
4d0795ae7a fix(tradein/admin): убрать показатели, которые не могут быть ненулевыми, и брать список источников из данных (#2674)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 1m1s
CI Trade-In / backend-tests (pull_request) Successful in 2m57s
Четыре находки одного класса: админка показывает числа, которые никогда не
бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как
«всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего.

1. «Помечено выбросов» (v_data_quality.outliers_flagged) — УБРАН вместе с
   колонкой listings.is_outlier. Механизм не «не доделан»: «выброс» у эстиматора
   вычисляется Tukey-фильтром по КОНКРЕТНОЙ подборке аналогов и живёт один
   запрос — один и тот же лот выброс для одной оценки и нормальный аналог для
   соседней. Persist-флаг на объявлении такое отношение выразить не может,
   реализовать пометку нечем.

2. http_requests / http_errors / returning_count / disappeared_count — УБРАНЫ.
   HTTP-запросы не считает ни один фетчер (заполнить нечем без сквозной
   инструментации). Ошибки и «пропало/вернулось» уже считает тот, кто их знает,
   и кладёт в counters jsonb: errors_count у pipeline, deactivated/revived у
   deactivate_stale_*. Отдельные колонки были бы вторым определением того же.

3. run_type — УБРАН из API, из таблицы админки и из схемы. Ни одно место кода
   его не задавало; DEFAULT из 051 подписывал 'city_sweep' даже proxy_healthcheck.
   Колонка «Тип» в UI заменена на «Источник» — там осмысленное значение.

4. Фильтр источников — теперь из данных (GET /scrape/runs/sources, SELECT
   DISTINCT source). Захардкоженная тройка не просто была неполной: сравнение
   точное, а строк с source='avito'/'cian'/'yandex' в таблице нет вообще, то
   есть каждый пункт фильтра давал пустую выдачу, и пустой выбор («Все») тоже —
   он молча подставлял source вкладки. Новый источник появляется в списке сам.

Числа с прода (tradein-postgres, 2026-08-06): is_outlier=true у 0 из 93 408
listings (NULL у 0 — только DEFAULT); четыре счётчика = 0 во всех 3244 прогонах
с миграции 015; run_type — одно значение на 3244 строки; 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.

Миграция 214 идемпотентна; v_data_quality пересоздан тем же DDL минус
outliers_flagged (порядок DROP VIEW → DROP COLUMN → CREATE как в 095).
2026-08-06 03:34:18 +05:00
673c02e5d6 Merge pull request 'fix(tradein): писатели наконец пишут то, что обещает схема — фото подсказок, статус «снято», события объявлений (#2674)' (#2682) from fix/2674-writers-honor-schema into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m50s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 6m36s
2026-08-05 22:12:29 +00:00
ab01f7cc48 fix(tradein): убрать невыводимые события, развести «снято» и «протухло» (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / 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 3m1s
Ревью PR #2682 нашло контрольную группу в наших же данных. Перепроверено
собственными запросами к проду — сходится, местами хуже заявленного.

1. delisted/relisted УБРАНЫ из писателя событий.
   Покрытие обхода за 14-18.07: domklik 99.9-100%, yandex 34-43%, cian 21-27%,
   avito 1.6-3.4%. Переходы за те же дни: domklik — снятий 1/2/0/2/4 в сутки и
   возвратов РОВНО 0 все пять суток; yandex — снятий 343-433 в сутки. Тот же
   обход, тот же день, разница только в покрытии: событие рождается тем, что
   скрейпер снова дошёл, а не тем, что объявление вернулось. Подтверждения:
   avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против
   контрольной ставки 1-4 (точность ≈4%); 4705 возвратов из 5493 за 12 дней
   (85.7%) — это 2-3.08, два дня после возобновления обхода.
   Сужение окна свежести сделало бы хуже (больше флапаний). Журнал из догадок
   хуже пустого журнала — не пишем. is_active убран из запроса целиком.
   Гейт-тест ослаблен до трёх типов + новый гейт «невыводимые НЕ пишутся».

2. TTL-путь пишет 'stale', а не 'closed'.
   Прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток
   против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая
   «дата продажи» одной датой. 'closed' остаётся только за 404: там ответила
   площадка. CHECK на колонке нет, миграция 212 обновляет только COMMENT.

3. change_time усечён до суток (date_trunc). С now() UNIQUE(source, change_time,
   type) работал только внутри прогона: второй прогон в те же сутки (2 августа
   их было два) давал дубли. Теперь заявленная идемпотентность действительно
   работает.

4. Комнатность в разборе заголовка стала необязательной: 1991 заголовок из
   25 055 (7.9%) — «Квартира-студия, 34,2 м², 9/10 эт.», обязательная группа
   роняла match и обнуляла все четыре поля. Чинит обоих писателей сразу
   (house_suggestions + house_placement_history, там 8.8% без площади).
   Студия → rooms=0 по конвенции kit'а, а не None.

Фальсификация: вернуть delisted — 1 красный; 'closed' на TTL-пути — 6;
обязательная комнатность — 2; now() вместо date_trunc — 1.
2026-08-06 03:01:05 +05:00
3e1b9a8b0d fix(tradein): чинит такт загрузки СберИндекса — иначе новый ERROR стал бы ложной тревогой (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m59s
Ревью PR #2681 опровергло исходную посылку по СберИндексу, и это подтвердилось
на моих же числах (все 24 прогона монитора, read-only):

  13-16.07  alert=1  age 73..76  latest=май
  17.07     alert=0  age 46      latest=июнь  ← день загрузки
  18-31.07  alert=0  age 47..60
  01-05.08  alert=1  age 61..65

Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст
считается от первого числа покрытого месяца → пол 46, потолок 74, порог 60
ВНУТРИ диапазона. Тревога срабатывала 14 суток из 28 без всякого застоя
источника: девять срабатываний были замером нашего собственного такта. Поднятие
до ERROR без этой правки завело бы ежедневное ложное событие две недели в месяц.

Миграция 212 переводит sber_index_pull на недельный такт (потолок ≈53 при пороге
60, запас 7 суток) вместо поднятия порога до 75 (запас 1 сутки — ломается от
любого сдвига окна). Цена: 9 запросов в неделю вместо 9 в 28 дней к публичному
sberindex.ru/api/sowa; прогон 4 секунды, 0 ошибок за всю историю.

Дополнительно по ревью:
- поллер Росреестра: ветка «файл найден в листинге, но HEAD не отдал zip» →
  ERROR (ровно поведение старой Bitrix-заглушки) + вписана в таблицу уровней;
- тестовый харнесс закрывает клиент событий (фоновый поток на каждый тест).

Refs #2674
2026-08-06 02:53:26 +05:00
43aaf91b97 fix(tradein): писатели наконец пишут то, что обещает схема — фото подсказок, статус «снято», события объявлений (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 2m56s
Три находки одного класса из эпика: колонка есть, писатель есть, тест на писателя
зелёный — а данные не появляются. Тестами это не ловится по построению, только
сверкой с продом.

1. house_suggestions: парсер выбрасывал imageLink, а INSERT не перечислял
   image_link + area_m2/rooms/floor/total_floors. 25 055 строк с NULL во всех
   пяти колонках, ~74 дня с миграции 064. Метрики парсятся из title тем же
   _parse_title, что и у placementHistory.

2. listings_snapshots.status: 'active' у всех 394 299 строк при 55 448 реально
   неактивных объявлений. Оба места вызова с литералом 'active' честны — там
   объявление действительно видели; не писал никто ветку «снято». Теперь оба
   места деактивации пишут снимок 'closed' в ТОЙ ЖЕ транзакции: TTL-задача
   (data-modifying CTE, все 4 источника через один deactivate_stale_listings)
   и 404 из avito_detail_backfill. Дата снятия перестаёт быть догадкой.

3. listing_source_events: схема знает 5 типов, писался 1 (price_change, 8288
   строк). Дописаны ветки delisted/relisted/edited/first_seen в тот же
   set-based statement — данные для них уже лежат в снимке. JOIN → LEFT JOIN
   LATERAL, иначе first_seen недостижим по построению; план #2607 (per-row
   index point-lookup по idx_lss_source_date) сохранён, проверено EXPLAIN на
   проде. Счётчики прогона теперь по типам, все пять всегда присутствуют —
   ровно они показали бы четыре нуля из пяти.

Миграция не нужна: все колонки и CHECK уже существуют.

Тесты: tests/test_2674_writers_honor_schema.py. Гейты сверяют писателя со
СХЕМОЙ (колонки INSERT против CREATE TABLE 064, типы событий против CHECK 079),
поэтому ловят и следующую забытую колонку. Фальсификация патч-методом: без
фикса 1 — 6 красных, без фикса 2 — 6, без фикса 3 — 4.
2026-08-06 02:29:58 +05:00
46bbb79881 fix(tradein): сигналы о сбоях наконец становятся событиями, а протухание кук предупреждает заранее (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
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 2m58s
В скрапер-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR),
поэтому любой сигнал уровня WARNING событием не становится — сколько бы раз он
ни срабатывал. Прод это подтвердил: монитор устаревания СберИндекса отработал
24 раза, 9 из них со staleness-вердиктом, событий ноль; куки Домклика протухли
2026-08-03 и об этом никто не узнал.

Разбирали не «поменять warning на error», а по каждому сигналу: сбой, из-за
которого данные перестают обновляться — событие; рутина и ожидаемые состояния —
лог. Плюс предупреждение ЗАРАНЕЕ там, где чинить нужно руками (куки Домклика —
по образцу #2658 для Циана, переиспользован тот же подход session_expires_at +
COOKIE_EXPIRY_WARN_DAYS).

У поллера Росреестра выход нового квартала оставлен уровнем info, но получил
явный capture_message(level="info"): новость хорошая, но требует ручного импорта
оператором, а INFO-строка живёт только до ближайшего редеплоя. logger.error для
неё был бы враньём в error-rate и стрик-алертах.

Оговорка: у GlitchTip-проекта сейчас нет ни правил, ни получателей (#2673) —
события станут видны в интерфейсе, но никому не отправятся.

Refs #2674
2026-08-06 02:29:11 +05:00
9f9086fa4d Merge pull request 'fix(tradein/imv): домовая оценка перестаёт врать про ремонт и тип дома (#2674)' (#2675) from fix/2674-house-imv-params into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m47s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-08-05 20:02:31 +00:00
4b4ab8b34c fix(tradein/imv): счётчики прогона в total_seen/new_count + лог дрейфа ремонта (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 2m51s
По ревью PR #2675.

1. counters прогона не заполняли выделенные колонки. _column_counts
   (scrape_runs.py) берёт total_seen из ключей total_seen|lots_fetched и
   new_count из new_count|lots_inserted — ни одного из них в дикте не было,
   поэтому все 39 прогонов этого source лежат в БД с total_seen=0. А mark_done
   по этой же колонке шлёт алерт «3 подряд done с нулевым результатом» (#2625):
   даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня
   выстрелил бы ложной тревогой про капчу. Добавлены total_seen=checked и
   new_count=saved. Трейд-офф назван в комментарии: на исчерпанной очереди
   checked=0 три дня подряд тоже даст алерт — но пустая очередь при ежедневном
   расписании это и правда сигнал.

2. _map_renovation_type молча схлопывал в 'cosmetic' любое незнакомое непустое
   значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но
   дрейф вокабуляра реален (70950 строк listings с пустым нормализованным
   ремонтом). Добавлен logger.debug на случай «непустое, но не в карте» —
   паритет с house_type_normalizer, который такой лог уже пишет.

3. Обоснование дефолта 'cosmetic' в докстринге заменено на более сильное по
   данным: это одновременно МОДА и МЕДИАННАЯ категория популяции
   (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
   needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка, а
   не просто «не край шкалы». Там же названа асимметрия: поштучный путь
   эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит —
   решение осознанное (иначе теряем ещё ~32% домов очереди), чтобы следующий
   читатель не принял это за недосмотр.

Refs #2674
2026-08-06 00:58:11 +05:00
0815319e1c fix(tradein/imv): домовая оценка перестаёт врать про ремонт и тип дома (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m51s
Три дефекта в house_imv_backfill, найденные системным поиском (эпик #2674/#2673).

1. Тип ремонта был захардкожен литералом 'cosmetic' — все 2685 запросов ушли
   как «косметический ремонт», хотя мода repair_state по объявлениям тех же
   домов другая: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631
   (косметика лишь 36%). Теперь renovation_type берётся из mode(repair_state)
   в том же агрегате, что уже считает медианы комнат/площади/этажа, и проходит
   через существующий estimator._IMV_REPAIR_MAP (ленивый импорт — estimator
   тянет scraper_adapters, а тот импортирует этот модуль). Второго словаря не
   заводим. Неизвестный ремонт (498 домов из 2685) остаётся 'cosmetic': это
   середина порядковой шкалы required < cosmetic < euro < designer, а не край,
   системного сдвига в одну сторону не даёт.

2. Неизвестный тип дома молча становился 'panel' — и когда типа нет вовсе, и
   когда он есть, но не совпал со словарём. Панель почти самый дешёвый класс
   (медиана по нашим же 2685 оценкам: block 122.6k < panel 128.8k <
   brick 131.1k < monolithic 145.9k руб/м2), то есть дефолт систематически
   занижал. На проде так уехали 363 дома совсем без типа и 75 домов с
   camelCase-типом из Циана (56 из них monolithBrick — минус 11.7% против
   monolithic). Теперь сырое значение прогоняется через общий
   scraper_kit.house_type_normalizer.normalize_house_type (знает monolithBrick /
   gasSilicateBlock / aerocreteBlock / stalin и SCREAMING-вокабуляр Яндекса),
   дефолт 'panel' убран: тип не распознан → house_type=None → дом помечается
   no_params ('unknown house_type') и запрос к площадке не тратится. 'other' и
   'wireframe' намеренно НЕ маппятся — честного соответствия у них нет.

3. Прогон не умел падать: 31 прогон подряд с saved=0 и ~35 ошибками из 50
   помечен 'done'. Тот же класс, что #2670/#2657 — успех определялся как «не
   поймали известное исключение». Теперь saved=0 при errors>0 → mark_failed.
   Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) остаётся done.

Балкон/лоджия оставлены константами намеренно: покрытие listings.has_balcony
13.8%, listings.balcony_loggia 9.4%, и колонки противоречат друг другу (по
has_balcony «есть» у 62%, а по balcony_loggia самый частый случай — loggia
5650 против balcony 2794). Мода по одному-двум объявлениям на таком покрытии —
шум, а не данные.

Причина, по которой бэкфилл не сохранил НИ ОДНОЙ оценки за 34 дня, — вне этого
модуля и здесь не чинится (детали и числа в описании PR): 1240 домов легли на
отказе браузерного сайдкара «нет прокси» (гейт #2616, 05.07-02.08), а после
возврата прокси 05.08 — 23 на Page.evaluate «Execution context was destroyed»
в tradein-browser и 12 на 403 Авито.

Refs #2674
2026-08-06 00:29:49 +05:00
4e9e4f558e Merge pull request 'fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666)' (#2671) from fix/2666-discount-plausibility-gate into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m8s
Deploy Trade-In / test (push) Successful in 2m51s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 1m16s
2026-08-05 19:20:08 +00:00
77ae08f207 fix(tradein): отказ гейта — факт про выборку вместо обещания надёжности (#2666)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
Правки по ревью PR #2671.

Текст «надёжная медиана начинается от 10» обещал то, чего мы гарантировать не
можем: пары — псевдореплики (одно объявление переиспользуется на многих
сделках, на живом кейсе Космонавтов 2-комн. 42 пары стоят на 2 различных
объявлениях), и 10 пар надёжности не дают. Теперь отказ сообщает факт: сколько
пар есть и что на такой выборке медиана гуляет на десятки п.п.

Формулировка диапазонной ветки укорочена: она дублировала street_only-
дисклеймер, который идёт следующим блоком. Проверено скриншотом отрендеренной
карточки — две формулировки подряд читались как стена текста; теперь три
однострочных хинта, на 820px — по две строки, переполнения нет.

В шапку секции добавлен потолок гейта, найденный ревью: бутстрап пересэмплировал
ПАРЫ, т.е. мерил дисперсию со стороны сделок, а доминирует дисперсия со стороны
ОБЪЯВЛЕНИЙ (джекнайф p90 17.3 п.п., max 63.8); 22 из 64 переживших групп стоят
на одном объявлении. Плюс нижняя граница оказалась слишком мягкой, а не строгой:
26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%. Оба пункта —
отдельная задача, здесь только зафиксированы, чтобы порог не перечитали как
гарантию.

Тесты: пустое утверждение "1" in explanation (всегда истинно из-за "10")
заменено на «всего 1 —». Добавлены два недостающих — отсутствие пар со скидкой
даёт explanation=None, и порядок проверок (3 пары по +80% отчитываются «мало
пар», а не «вне диапазона»).
2026-08-06 00:16:13 +05:00
b88535425e fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 1m10s
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
/sales-vs-listings отдавал median_discount_pct без всякой проверки: после
сегментного гарда #2660 по `%Космонавтов%` 2-комн. значение уехало с −11.9%
на +36.4%, то есть пользователю написали бы «продали на 36% дороже, чем
просили». Корень унаследованный — пейринг ДКП↔объявление идёт по улице без
номера дома (ADR #721), так что на длинной улице в пару попадают квартиры
разных ценовых классов. Пейринг здесь не чиним, перестаём показывать число,
которому нельзя верить.

Пороги подобраны по проду (симуляция эндпоинта на 238 реальных
пользовательских запросах из trade_in_estimates, 128 дали хотя бы одну пару):
- MIN_PAIRS = 10 — бутстрап по 12 плотным группам: p90 отклонения медианы
  подвыборки от полной 18.8 п.п. при k=5, 12.0 при k=10, 9.9 при k=15.
  Кривая ломается на 10; совпадает с уже принятым в продукте
  sell_time_sensitivity_min_n_lots.
- Санитарный диапазон [−60%, +20%] — асимметричный. Сверху распределение
  разорвано (…+16.9, пусто, +33.7…+103.1), отсечка попадает в разрыв; ни один
  городской бакет asking_to_sold_ratios не даёт плюса вообще (max 0.9132).
  Снизу разрыва нет (у большого минуса есть механизм — занижение цены в ДКП),
  граница грубая «заведомо не рынок»: 2.5× худшего бакета (студии, −23.8%).

Форма отказа — не пустота: новое поле median_discount_explanation по образцу
confidence_explanation оценщика, фронт рендерит его вместо числа. Гаснет ровно
строка «медианный торг»: сделки, медиана ₽/м², диапазон, linkage_rate_pct и
per-pair discount_pct не трогаются.
2026-08-05 23:49:03 +05:00
c9f71da484 Merge pull request 'feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571)' (#2663) from feat/2571-login-throttle into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m49s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m10s
2026-08-05 18:31:27 +00:00
96d62e418b Merge pull request 'fix(tradein/scraper): пропуск расписания пишет строку прогона со статусом skipped (#2658)' (#2662) from fix/2658-loud-skip-status into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m6s
Deploy Trade-In / test (push) Successful in 2m42s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 2m11s
2026-08-05 18:24:04 +00:00
40fdf11f19 fix(tradein/auth): не ронять и не занимать пул на замедлении входа (#2571)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
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 2m56s
Ревью нашло два способа положить сервис ровно под той нагрузкой, ради
которой писалась защита.

Первый: `min()` вычисляет оба аргумента, поэтому `float(2 ** (excess - 1))`
при 1045 неудачах по имени за окно падал с OverflowError. Счётчик ничем
не ограничен сверху — `record()` только копит метки и на лимит не смотрит.
С этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и
без записи в аудит: терялись обе ценности PR, и трение, и сигнал. Показатель
степени зажат; 2**16 заведомо выше любого разумного потолка, поэтому видимое
поведение не меняется.

Второй: сон шёл внутри области жизни сессии БД. В дефолтном режиме
`get_identity_db` отдаёт ту же сессию, что `get_db`, а SELECT в
`get_user_by_username` оставляет её в открытой транзакции — соединение
висело занятым все восемь секунд. Пятнадцати одновременных неудач хватало,
чтобы выбрать QueuePool целиком и уронить любой другой эндпоинт по
pool_timeout. Отказ в обслуживании против всех сразу — хуже той блокировки
учётки, ради ухода от которой замедление и выбиралось. Соединение теперь
возвращается в пул перед сном.

Заодно: длина имени ограничена 64 (верх CHECK'а реестра) — сырое имя
становится ключом обоих лимитеров, а их словарь при часовом окне не
подчищается; и явно записано, что `limit` у счётчика на имя не порог.
2026-08-05 23:14:11 +05:00
d173163025 fix(tradein): тест ловит копию константы, а не equality; честный комментарий про вклад свежести (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m44s
По ревью PR #2664.

1. test_freshness_window_is_the_estimator_constant_not_a_copy проверял
   `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` — CPython кэширует
   малые int, поэтому скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы
   ПРОШЁЛ, хотя докстринг обещает ловить ровно это. Прошлая фальсификация
   срабатывала лишь потому, что откат удалял имя целиком (AttributeError).
   Теперь проверяем исходник через inspect.getsource — фальсифицировано
   подстановкой копии литерала вместо импорта: тест краснеет.

2. Комментарий в location_index.py приписывал свежести чужую заслугу.
   Прод-разложение: из −14.8% сдвига городской медианы −14.7 п.п. даёт
   сегментный гард и лишь −0.18 п.п. свежесть. Для этой метрики свежесть —
   не коррекция смещения, а страховка на будущее, оплаченная третью пула
   (3 504 вторичных строки, из них 2 724 живые) и ростом дисперсии: на центре
   ЕКБ n 423 → 86, индекс гуляет по выбору окна на 12-14 п.п. Размен верный,
   но он должен быть написан как размен.

   Там же задокументирован новый режим отказа: свежесть связала витрину со
   здоровьем сбора — встанет скрейпинг на 14 дней, и insufficient_data
   прилетит всем пользователям разом. Учитывая, что #2574 это месяц молчаливой
   поломки сбора, сценарий не гипотетический.

Окно свежести не меняю — вопрос вынесен отдельно.

Refs #2660
2026-08-05 23:13:22 +05:00
b800760c24 fix(tradein/scraper): фильтр skipped в админке + освежение схлопнутой строки (#2658)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
Правки по ревью PR #2662.

Фильтр статуса. `GET /admin/scrape/runs?status=skipped` отдавал 422 — 'skipped' не
было в Literal, а во фронте не было чипа. Строки рисовались, но задать вопрос
«что сейчас пропускается» на единственной поверхности, построенной ровно для
этого, было нельзя. Добавлено в оба места (translateStatus «пропущено» и
нейтральный бейдж уже умели).

Схлопывание освежает строку. UPDATE двигал только finished_at/heartbeat_at, из-за
чего живой стрик замерзал: списки прогонов сортируют ORDER BY started_at DESC и
берут limit=20, поэтому 37-дневный пропуск утонул бы под свежими прогонами других
источников — след в базе есть, на экране нет. Теперь started_at = NOW(), а начало
стрика переезжает в counters.first_skip_at; сортировку общего списка не трогаем
(она про все источники, чинить надо было одну строку). Там же обновляется
counters.detail — иначе в строке 37 дней висел текст «протухли 1 день назад»,
хотя именно эта цифра и есть предмет issue. jsonb_set заменён на `||` +
jsonb_build_object: три вложенных jsonb_set читать в 3 ночи невозможно, а NULL в
jsonb_set обнуляет весь counters.

Поиск последней строки. `ORDER BY id DESC` не ложится на индекс
(source, started_at DESC) из миграции 015 — для unknown_source (тикает каждые
60 с бессрочно) это отбор всех строк источника с сортировкой раз в минуту.
Теперь ORDER BY started_at DESC, id DESC.

session_expires_at получил valid_only: предупреждение «скоро протухнут» считает
срок ИМЕННО той записи, которую взял load_session — при нескольких аккаунтах
свежайшая-любая может быть чужой протухшей строкой. Диагностика после None
по-прежнему смотрит на свежайшую любую (валидных там нет по определению).

Запись пропуска намеренно НЕ обёрнута в свой try/except: если db.execute падает,
то падает и claim следующего расписания в этом же тике — тик срывается в любом
случае, а глушить исключение здесь значило бы вернуть ровно тот немой пропуск,
ради которого заведён #2658. Самовосстановление через 60 с.
2026-08-05 23:10:47 +05:00
837ad8cfd4 fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 7s
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 2m42s
Пользовательская половина разбора #2574: витрины читают listings без сегмента
и без свежести, поэтому показывают числа, посчитанные не по тому пулу.

1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings().
   27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и
   девелоперский прайс (который не торгуется) формировал показываемый процент
   торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция
   намеренно смотрит и снятые объявления, иначе к сделке нечего подставить.
   Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая
   перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205).

2. location_index — предикат свежести + сегментный гард в обоих запросах
   медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на
   тот же пул, на котором считается цена; окно свежести берётся импортом
   LISTINGS_FRESH_DAYS, второго определения константы не заводим.

3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем:
   рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе).
   Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть
   как «всё собирается».

Refs #2660
2026-08-05 22:39:48 +05:00
7d154de1f7 feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 6s
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 2m50s
Лимит на логине ключевался парой (username, IP), поэтому распределённый
перебор одного имени с тысячи адресов получал по 5 попыток с каждого
источника и не упирался ни во что. После снятия Caddy basic_auth с
/trade-in (#2558) POST /auth/login — единственная ручка, доступная из
интернета без кредов, так что дыра открыта прямо сейчас.

Поверх существующего per-IP лимита добавлен глобальный счётчик неудач
на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит
задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы
вектором отказа в обслуживании против конкретного человека — не зная
пароля, злоумышленник гарантированно выключал бы чужой вход.

Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и
из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый
401» для несуществующего имени стал бы оракулом существования учётки, то
есть ровно той user-enumeration, от которой уже защищают одинаковый
generic-ответ и безусловный bcrypt.
2026-08-05 22:38:23 +05:00
0b54b96984 fix(tradein/scraper): пропуск расписания пишет строку прогона со статусом skipped (#2658)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m41s
Пропуск наступившего окна был немым: logger + сдвиг next_run_at, ни строки в
scrape_runs, ни изменения last_run_at. cian_history_backfill так простоял 37 дней
на протухших куках Циана и снаружи выглядел работающим — next_run_at исправно
двигался вперёд, а docker-логи с warning'ом терялись на каждом редеплое.

Статус 'skipped' заведён ещё миграцией 015 и локализован во фронте («пропущено»),
но в проде имел 0 строк — механизм построен и ни разу не использован. Задействуем
его во всех пяти местах, где расписание пропускалось без следа: kit `_claim_run`
(already_running / concurrent_claim / running_appeared_under_lock), kit
`scheduler_loop` (unknown_source) и продуктовый cian `pre_claim`. Причина — слаг в
`error`, по нему «нет кук» отличается от «уже бежит» запросом, а не грепом логов.

Подряд идущие одинаковые пропуски схлопываются в одну строку со счётчиком
`counters.skips`: «уже бежит» и «неизвестный source» не двигают next_run_at и
иначе плодили бы строку каждый тик (60 с).

Алерт про куки жил в недостижимой ветке: он стоял там, где verify_session вернул
None, а на протухших куках load_session сам фильтрует expires_at_estimate > NOW()
и отдаёт None ещё в первой, немой ветке. Теперь алерт в обеих ветках и через
logger.error — в scraper-контейнере GlitchTip поднят с LoggingIntegration
(event_level=ERROR), поэтому прежний capture_message(level="warning") событием не
становился. Плюс предупреждение ЗАРАНЕЕ (COOKIE_EXPIRY_WARN_DAYS=5) в том же
pre_claim: обновление кук — ручная операция, алерт по факту протухания приходит,
когда сбор уже встал.

Монитор нулевых прогонов (#2625) не трогаем: обе alert-выборки отбирают
failed/banned/done/cancelled, поэтому 'skipped' в стрик не попадает и его не
прерывает — пропуск не «прогон вернул ноль лотов», смешивать нельзя.
2026-08-05 22:35:51 +05:00
17fcf746f7 Merge remote-tracking branch 'origin/main' into fix/2603-geocode-city-hint-tails
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 2m41s
# Conflicts:
#	tradein-mvp/backend/app/api/v1/admin.py
2026-08-05 20:25:28 +05:00
5ecd5361fd fix(tradein/geocode): гейт мусорного города вынести в общий хелпер и прошить в admin-путь (#2603)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 7s
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 2m42s
Первый коммит починил только scripts/geocode_deals_nominatim.py — ручной скрипт.
Тот же дефект оставался на живом пути: POST /admin/geocode-missing?target=deals
отдавал сырой row["city"] в city_hint, а deals.city росреестровое и в хвосте
распределения содержит не-города («Бессонова», «Билейский рыбопитомник»). Любой
не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и уезжает префиксом в запрос
провайдеру, то есть мусорный хинт хуже отсутствия хинта.

Гейт вынесен в geocoder.known_city_hint (сверка с SVERDLOVSK_OBLAST_CITIES —
тем же набором, который уже питает _names_non_ekb_city / _ekb_local_tiers_allowed)
и переиспользуется всеми тремя потребителями city_hint: скриптом, admin-ручкой и
задачей geocode_missing. Копий функции нет — четвёртый потребитель, если появится,
получит гейт сам.

Тесты: мусорный город -> хинт не передаётся, валидный -> передаётся; проверено
фальсификацией (без фикса все три новых теста краснеют).
2026-08-05 20:20:41 +05:00
00bc07a55f fix(tradein/proxy): backup-узел должен быть пригоден + рычаг снятия бана (#2600 п.2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 2m45s
Правки по deep-review PR #2654.

MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.

MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).

LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.

LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.

LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.

Refs #2600
2026-08-05 17:45:07 +05:00
ec17886d60 fix(tradein/geocode): прошить city_hint в deals-скрипт + развести счётчики гейта (#2603)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m46s
Хвосты после #2601 (замыкание петли «город → геокодер»).

1. scripts/geocode_deals_nominatim.py — непрошитый sibling-caller.
   Скрипт группировал `GROUP BY address` и звал `geocode(address, db)` без
   города, хотя deals.city (миграция 177) заполнена на 100%: один и тот же
   текст адреса из разных городов схлопывался в одну группу, один geocode-вызов
   и один UPDATE по тексту адреса. Теперь — та же форма, что в #2601:
   группировка по паре (address, city), city_hint в geocode(), UPDATE и
   mark-tried через `city IS NOT DISTINCT FROM` (обычное `=` не ловит NULL-город
   → NULL-группа не обновлялась бы вовсе).

   Хинт передаётся ТОЛЬКО для значений из geocoder.SVERDLOVSK_OBLAST_CITIES:
   deals.city росреестровое, в хвосте лежит мусор («Бессонова», «Бердюгина»,
   «Билейский рыбопитомник»), а любой не-ЕКБ хинт жёстко закрывает EKB-локальные
   тиры и подставляется в запрос провайдеру — мусорный хинт хуже отсутствия
   хинта. Словарь переиспользован, а не заведён свой: тот же набор уже питает
   гейты самого геокодера (_names_non_ekb_city / _ekb_local_tiers_allowed) и
   estimator._resolve_target_city.

2. tasks/backfill_listings_coords_geoportal.py — наблюдаемость городского гейта.
   Добавлен skipped_non_ekb_by_column (+ в to_counters и в DONE-логи): колоночный
   гейт стоит перед парсером адреса, поэтому по мере раскатки областных
   развёрток (#2598) строки потекут из no_address в skipped_non_ekb и общий
   счётчик поменяет смысл ровно тогда, когда по нему валидируют раскатку.
   Старый счётчик не тронут — остаётся суммой обоих гейтов, вклад текстового
   считается разностью.

3. tests: test_admin_geocode_missing_passes_city_hint параметризован на
   target="deals" (колонка city есть в обеих таблицах, ветка была не покрыта).

4. tasks/geocode_missing.py: dry-run лог печатает city — он с #2594 часть ключа
   группы, без него две строки dry-run неотличимы.

Refs #2603
2026-08-05 17:36:07 +05:00
964867a943 feat(tradein/proxy): здоровье прокси по паре «узел × источник» (#2600 п.2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
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 2m43s
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.

Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.

Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.

Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.

Refs #2600
2026-08-05 17:14:12 +05:00
d362b16d7c fix(tradein/proxy): доводить сигнал бана площадки до пула (#2600 п.1) (#2653)
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 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m40s
2026-08-05 11:37:35 +00:00
aa5bb76822 fix(tradein/proxy): отличать ручное выключение узла от авто-выключения (#2610) (#2652)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m35s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m17s
2026-08-05 10:18:02 +00:00
59c072fc4c chore(tradein): удалить мёртвые mobileproxy env-переменные и rotate-ip (#2616 шаги 2-3) (#2650)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 2m45s
Deploy Trade-In / build-browser (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m29s
2026-08-05 09:35:54 +00:00
ac0c472faf fix(tradein/auth): убрать устаревшее «Caddy basic_auth required» из 401 (#2573) (#2649)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m38s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m6s
2026-08-05 08:45:57 +00:00
90332e9827 fix(tradein): area-бакеты в asking→sold — и расчёт, и применение (#2620) (#2648)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 1m28s
2026-08-05 08:11:04 +00:00
2aa79e2143 fix(tradein): честный радиус поиска аналогов на карте v2 (#2632) (#2643)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m0s
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 1m23s
2026-08-05 06:13:58 +00:00
86ea09f2d5 fix(tradein/scraper): капча Cian и заглушки Yandex — banned, не done (#2625) (#2642)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m34s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m57s
2026-08-04 19:03:33 +00:00
ef2b07ee9c fix(tradein/proxy): один прокси на сессию браузера вместо смены на каждом запросе (#2640)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m26s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-08-04 18:45:37 +00:00
7d13e93792 fix(tradein/scraper): не ходить через мёртвый запасной прокси (#2634)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 38s
Deploy Trade-In / build-browser (push) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 2m44s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 2m27s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-08-04 18:27:49 +00:00
bot-backend
a95c38bdcc fix(tradein/scraper): подключить браузерный путь Авито к пулу прокси
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-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) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
2026-08-02 17:31:00 +03:00
8b79e9659b fix(tradein/estimate): не строить цену по одному-двум аналогам, не отдавать 0 ₽ (#2629)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m15s
2026-08-02 12:41:48 +00:00
0abfe020df fix(tradein/geocoder): строгий матч литеры дома + починка DaData region-фильтра (#2622)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m36s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m26s
2026-08-02 12:06:44 +00:00
c1b407527b fix(tradein/ui): городской фильтр в витрине «сделки против объявлений» (#2583 H4) (#2627)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m17s
2026-08-02 11:54:33 +00:00
bot-backend
86afbf9e6f fix(tradein/estimate): не применять хедонику к неправдоподобному году постройки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m40s
house_metadata (OSM/кадастр) отдаёт year_built без валидации; на проде
встретился 1829, который клампился в нижний пол хедонического фактора
(estimate_hedonic_factor_min=0.75) и резал выкупную цену на фикс. -25%
без физического смысла — модель никогда не видела осмысленного объёма
домов старше 1955 (см. COHORTS). Год вне [1917, текущий+3] теперь
трактуется как отсутствующий (None, нейтральный year-term) вместо
клампа, с WARNING-логом по house_id/адресу.

Единая точка входа _sanitize_build_year() в estimate_quality() перед
cohort-фильтром, house-match scoring и хедоническим фактором — покрывает
оба источника года (payload.year_built и house_meta.year_built).
2026-08-02 14:04:11 +03:00
f488cbcf03 Merge pull request 'fix(tradein/snapshot): починить зависающий запрос снапшотов и бюджет времени (#2607)' (#2618) from fix/tradein-snapshot-query-perf 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 2m35s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m21s
2026-08-02 09:19:20 +00:00
e484b5cce9 fix(tradein/pricing): скоупить сторону объявлений по городу, как сторону сделок (#2583 H2) (#2617)
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 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 1m23s
ask_side/ask_global получают предикат (city IS NULL OR city ILIKE :asking_city), симметрично deal-стороне. Областные объявления (развёртки с 12 июля) занижали ask-медиану и завышали коэффициент выкупа на 2.5-4.9%. NULL-толерантность намеренная: listings.city пока заполнена не у всех источников.

Refs #2583
2026-08-02 09:10:35 +00:00
8db67fe4d0 feat(tradein): DSN к БД auth собирается из AUTH_DB_PASSWORD, а не дублирует секрет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 2m43s
Чтобы включить IDENTITY_STORE=auth, до этого требовалось положить в
runtime-окружение полный AUTH_DATABASE_URL с паролем внутри — при том что
пароль уже лежит там же отдельной переменной AUTH_DB_PASSWORD (её читает
deploy-пайплайн для ALTER ROLE). Один секрет в двух местах разъезжается:
сменили пароль роли, DSN остался старым — вход ложится молча и целиком.

Теперь явный AUTH_DATABASE_URL по-прежнему выигрывает (обратная совместимость
и аварийный обход, скажем sslmode); если он пуст, а AUTH_DB_PASSWORD задан,
DSN собирается из частей. Части переопределяемы через AUTH_DB_HOST, _PORT,
_NAME, _USER.

Дефолт хоста — gendesign-postgres, не postgres. Внутри стека «Меры» имя
postgres резолвится в ЕЁ СОБСТВЕННЫЙ контейнер (tradein-postgres), и такой
дефолт не упал бы «неизвестным хостом», а молча увёл бы аутентификацию в живую
БД tradein, где нет ни роли auth_app, ни таблиц реестра. Нужный сервер виден по
алиасу gendesign-postgres в сети gendesign_shared, к которой tradein-backend
подписан.

Пароль и имя пользователя экранируются quote(safe=""). Имя БД и хост —
намеренно нет: SQLAlchemy раскодирует обратно только userinfo, а path отдаёт
как есть, поэтому quote("c/d") уехало бы в сервер литеральным c%2Fd. Найдено
прогоном, закреплено тестом.

Закрыта реальная утечка на пути ЯВНОГО AUTH_DATABASE_URL: на «почти URL»
SQLAlchemy доходит до int(port) и падает ValueError с символом ПАРОЛЯ в тексте
(он съезжает на позицию порта). Без обрыва цепочки обломок печатался бы в
traceback, то есть в логи и GlitchTip. Теперь ValueError и ArgumentError
перевыбрасываются своим сообщением from None; тест рендерит traceback целиком и
проверяет, что пароля там нет.

Пустое значение AUTH_DB_PORT больше не роняет импорт. Порт типизирован int и
валидируется до всякой нашей логики, а settings создаётся на уровне модуля —
пустая строка уводила контейнер в restart-loop В ЛЮБОМ режиме, включая
дефолтный tradein, где к БД auth нет ни одного обращения.

Прод не меняется: при IDENTITY_STORE=tradein (дефолт) ничего из этого не
читается и соединение с auth не открывается.

Тесты: +16 профильных, 118 passed на связке auth-сьютов. Проверено
исполнением: пустой порт даёт 5432; пароль со спецсимволами экранируется и в
открытом виде в DSN не встречается.
2026-08-02 12:05:41 +03:00
bot-backend
b586b5ff68 fix(tradein/snapshot): починить зависающий запрос снапшотов и добавить бюджет времени (#2607)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m37s
Root cause: event-diff CTE джойнил "today" (снимок за CURRENT_DATE) с "prior"
(DISTINCT ON по всей listing_source_snapshots, ~2.6-2.8M строк) обычным JOIN.
Планировщик оценивал today в 1 строку (свежевставленные в той же транзакции
строки ANALYZE ещё не видел) → Nested Loop без Materialize пересчитывал
DISTINCT ON по всей таблице заново на каждую из ~80-140k реальных строк today
(EXPLAIN на проде: cost≈300k на этом шаге) — прогон не укладывался ни в 6h
zombie-порог, ни в сутки, каждую ночь минимум с 19 июля.

Переписано на JOIN LATERAL (per-row indexed point-lookup через idx_lss_source_date,
cost упал до ~4.4/строку). Плюс budget_sec → SET LOCAL statement_timeout как
defense-in-depth (по образцу geocode_missing_listings) — задача теперь честно
падает в mark_failed вместо того чтобы висеть сутками, если план когда-нибудь
разрегрессирует снова.

Зомби-детектор (reap_zombies) не тронут — он только помечает scrape_runs.status,
не убивает backend (нет pid/application_name в схеме run'а); pg_terminate_backend
для этого — отдельный follow-up, не в этом PR.
2026-08-02 11:55:43 +03:00
887df54908 Merge pull request 'feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600)' (#2611) from feat/tradein-asocks-rotation into main
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 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m2s
2026-08-01 19:23:51 +00:00
bot-backend
bed2b7bca9 fix(tradein/proxy): pin ASocks rotate_url host — не слать токен на чужой прокси (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / 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 2m46s
scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy
changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки
хоста Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы на чужой провайдер —
security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only,
хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый
запрос без Authorization (смысл ручной ротации — конкретный провайдер).

Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить
ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry
сообщений (не только reason/note).
2026-08-01 22:12:52 +03:00
bot-backend
b1563b86cb feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m40s
2026-08-01 21:41:12 +03:00
bot-backend
876b666424 fix(tradein/proxy): не отдавать в fallback последний узел выделенной affinity (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / 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 2m44s
Ревью PR #2609: domclick — ровно один узел (прод scrape_proxies.id=1),
намеренно вырезанный из общего пула через provider_affinity='domclick'
(см. 173_scrape_proxies_add_domclick_affinity.sql) — QRATOR банит всё,
кроме этого одного чистого residential-адреса. Fallback-запрос из
предыдущего коммита мог законно забрать его под avito/cian/yandex,
оставив domclick (сейчас исправно собирает: 6501 активных объявлений,
368/сутки) без прокси вообще — чинили бы один источник ценой полной
поломки другого.

- acquire(): fallback-SELECT дополнен условием "affinity='any' ИЛИ есть
  ДРУГОЙ enabled-узел той же affinity" через коррелированный EXISTS-
  подзапрос (WHERE + FOR UPDATE SKIP LOCKED + ORDER BY last_ok_at NULLS
  LAST, id — сохранены). Кандидат с единственным enabled-узлом своей
  выделенной affinity в fallback не участвует.
- Тесты: единственный domclick-узел → acquire('avito') возвращает None;
  второй enabled domclick-узел появляется — fallback снова срабатывает.
- Починен мок FakeSession (tests/services/test_proxy_pool.py):
  ветка "mark_health ok" раньше ставила enabled=True безусловно по
  совпадению общей подстроки "SET consecutive_fails = 0" (одинаковой в
  старом и новом SQL) — test_mark_health_ok_revives_disabled_proxy
  проходил бы и против кода без реанимации. Теперь ставит enabled=True
  только если в тексте SQL реально есть "enabled". Та же проблема была
  и в fallback-ветке (protects_last_node переопределял логику в Python
  независимо от SQL) — исправлено аналогично: применяется, только если
  в SQL реально есть EXISTS-подзапрос.
2026-08-01 21:36:02 +03:00