`run_geocode_missing_listings` рекламирует `budget_sec` как максимальное время
прогона, но проверка стояла после возврата из батча. Внутри батча цикл шёл по
всем 200 адресам и часов не смотрел, то есть фактический потолок был
`budget_sec + один полный батч`.
Замер: прогон 5017 (27.08, `budget_sec=1800`) шёл 3150 с — 175 % бюджета, и
вышел не по бюджету, а по дренажу: бюджетная ветка за 52 минуты не выполнилась
ни разу.
Пока адрес стоил ~1.9 с это терялось в шуме. После общего ограничителя темпа
Nominatim (#2953) средняя цена 4.5 с, а на трудном хвосте (tier-1 + до 4
typo-вариантов под паузой 1 с, плюс retry×3) — до 33 с. Полный батч из таких
адресов уезжает на ~110 минут поверх бюджета, при окне расписания 06:00–09:00.
Дедлайн теперь передаётся В батч и проверяется на каждом адресе. Оборванный
батч — штатный исход: `geocode_tried_at` проставлен только у обработанных пар,
остальные попадут в выборку следующего прогона.
Отдельный флаг `budget_exhausted` нужен потому, что `addresses_total` на
оборванном батче равен размеру ВЫБОРКИ (== batch_size) — ветка дренажа
`addresses_total < batch_size` не сработала бы, и обёртка крутила бы цикл
дальше. Тест на это падает без флага (проверено снятием ветки).
Тесты: 5 новых, все три несущие проверки падают без соответствующей правки.
37 passed локально.
Closes#3151
Первый коммит починил только 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. Копий функции нет — четвёртый потребитель, если появится,
получит гейт сам.
Тесты: мусорный город -> хинт не передаётся, валидный -> передаётся; проверено
фальсификацией (без фикса все три новых теста краснеют).
Хвосты после #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
Ночная очередь geocode_missing_listings была на 98.5% забита is_active=false
объявлениями чужих регионов (Новосибирск/Казань/Челябинск/Тюмень/Ижевск...) без
улицы и дома. ORDER BY listings_count DESC ставил такой мусор в начало очереди
(у 'Новосибирская обл.,Новосибирск' — 214 listings, у реального адреса — 1-2),
поэтому Nominatim-бюджет (1 req/sec) съедался мусором и до активных адресов
дело не доходило: 8 ночных прогонов подряд saved=0.
Добавлен AND is_active в SELECT. UPDATE (lat/lon и оба tried_at) намеренно
оставлены без этого фильтра — координаты и backoff-метка принадлежат паре
(address, city) как тексту, не конкретному listing; is_active=false дубликат
той же пары и так навсегда исключён из будущих SELECT, а unfiltered UPDATE
проставляет ему ответ бесплатно (Nominatim-вызов уже оплачен активным
листингом) на случай реактивации.
Refs #2604
Замыкает петлю "город объявления -> геокодирование" (issue #2594, шаг 2/3).
listings.city (миграция 196) заполняется скрапером из контекста развёртки, но
три caller-места геокодера решали город по ТЕКСТУ адреса и игнорировали
колонку - голый адрес без города в тексте ("ул. Победы, 30", тагильский)
уходил в Екатеринбург.
- app/tasks/geocode_missing.py: группировка по (address, city) вместо
address, city_hint в geocode(), UPDATE/tried_at-пометка по паре через
city IS NOT DISTINCT FROM :city (обычный `=` не поймал бы NULL-город и
не даёт нужной симметрии между группами).
- app/tasks/backfill_listings_coords_geoportal.py: гейт по колонке city
ПЕРЕД матчем против EKB-only ekb_geoportal_buildings, ПЕРЕД текстовым
гейтом _names_non_ekb_city (сохранён как fallback для city IS NULL).
Это окно идёт раньше geocode_missing_listings, поэтому раньше успевало
испортить координаты первым.
- app/api/v1/admin.py: per-ID endpoint /geocode-missing читает city из
SELECT (listings.city / deals.city) и передаёт как city_hint.
geocoder.py не тронут (запрещено ТЗ).
Тесты: falsification-прогон (stash impl, тесты остаются) - 7 failed / 36
passed на старом коде, все 7 - новые тесты на новое поведение; после
stash pop - 43 passed / 0 failed. Полный pytest tradein-mvp/backend:
2970 passed, 1 failed (pre-existing tests/test_search_api.py::test_search_cache_hit,
несвязан), 9 skipped.