gendesign/tradein-mvp/backend/app/tasks/geocode_missing.py
bot-backend 8cdb195e18
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 9s
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 4m45s
CI / backend-tests (pull_request) Has been skipped
fix(tradein/geocode): бюджет прогона не был потолком — проверялся только между батчами
`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
2026-08-27 19:10:53 +03:00

471 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Batch geocoding для listings с NULL lat/lon.
Запускается:
- Manual через POST /admin/scrape/geocode-missing-listings
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
— wired into in-app scheduler, window 06:00-09:00 UTC.
Pattern: dedup по паре (address, city) — 1 уникальная пара → 1 geocode call → UPDATE
всех listings с этим address+city (#2594 шаг 2/3: listings.city теперь заполняется
скрапером из контекста развёртки — один и тот же текст адреса в разных городах
(«ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) должен получать РАЗНЫЕ координаты, а
не схлопываться в один geocode-вызов и один UPDATE по тексту адреса).
Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
SELECT фильтрует `is_active` (#2604 п.1): на проде очередь была на 98.5% забита
мёртвыми объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без is_active —
`ORDER BY listings_count DESC` ставил их В НАЧАЛО (у мусорного адреса вида
«Новосибирская обл.,Новосибирск» — сотни listings, у реального адреса — 1-2), поэтому
весь batch-бюджет (Nominatim 1 req/sec) съедался мусором и до настоящих адресов дело
не доходило (8 ночных прогонов подряд: saved=0). UPDATE после успешного/неуспешного
geocode НЕ фильтрует is_active — см. комментарии у соответствующих UPDATE ниже.
Отличие от /admin/geocode-missing (per-ID):
- Этот модуль группирует по (address, city) → меньше API calls (dedup), но не
схлопывает разные города с одинаковым текстом адреса.
- Поддерживает all sources включая Avito (после PR #487 убрали jitter).
- Возвращает GeocodeBackfillResult с детальными counters.
- Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days;
при geocode failure помечает tried_at=NOW() → пара (address, city) не
переотбирается в этом же run.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
from app.services.estimator import _geocode_is_coarse
from app.services.geocoder import geocode, known_city_hint
logger = logging.getLogger(__name__)
@dataclass
class GeocodeBackfillResult:
addresses_total: int = 0 # unique addresses pending geocode в этом batch
addresses_processed: int = 0 # фактически обработано
addresses_geocoded: int = 0 # успешно получили coords
addresses_failed: int = 0 # geocoder вернул None
listings_updated: int = 0 # total listings затронуто (1 address → N listings)
cache_hits: int = 0 # из geocode_cache (instant)
cache_misses: int = 0 # реальные geocoder calls
duration_sec: float = field(default=0.0)
budget_exhausted: bool = False # батч оборван дедлайном, а не разобран до конца
async def geocode_missing_listings(
db: Session,
*,
batch_size: int = 200,
dry_run: bool = False,
deadline_monotonic: float | None = None,
) -> GeocodeBackfillResult:
"""Geocode listings с NULL coords (любой source).
Steps:
1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active
AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC
LIMIT batch_size
(приоритет парам address+city с большим числом listings — больший ROI per
geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3:
один и тот же текст адреса в разных городах — разные записи. `is_active` —
#2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда
не попадут в выдачу пользователю)
2. Для каждой пары (address, city):
- geocode(address, db, city_hint=known_city_hint(city)) — auto-cache
(hit или miss); хинт гейтится словарём городов области (#2603)
- Если есть результат: UPDATE listings SET lat, lon
WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL
(IS NOT DISTINCT FROM, а не `=` — стандартная SQL NULL-семантика: `city = NULL`
никогда не true, поэтому обычным `=` группа с city IS NULL не обновилась бы
вообще ни для одной строки; `IS NOT DISTINCT FROM` трактует NULL=NULL как
совпадение, оставаясь строгим при непустом city — нужная нам симметрия)
- PostGIS trigger (listings_set_geom_trg) автоматически обновит geom
3. Log progress каждые 50 addresses.
Args:
batch_size: max addresses to process per call (default 200 ≈ 3.5 min Nominatim)
dry_run: только показать что бы сделалось, без UPDATE
deadline_monotonic: значение `time.monotonic()`, после которого батч
обрывается на границе адреса (#3151). None — без дедлайна.
Returns:
GeocodeBackfillResult с counters.
"""
start = time.monotonic()
result = GeocodeBackfillResult()
# 1. Найти top-N пар (address, city) с NULL coords (DESC by occurrence count).
# Группировка по паре, а не только по address (#2594 шаг 2/3) — один и тот же
# текст адреса в разных городах (напр. «ул. Победы, 30» в ЕКБ и в Нижнем Тагиле)
# это разные записи с разными координатами, их нельзя схлопывать в один
# geocode-вызов. GROUP BY address, city трактует NULL city как отдельную
# группу (стандартная SQL-семантика группировки NULL как равных друг другу).
# Фильтруем пары, по которым геокодер уже пробовал и не нашёл — они помечены
# geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
# failed-пары не переотбираются бесконечно.
#
# AND is_active (#2604 п.1) — очередь без этого фильтра на 98.5% состояла из
# is_active=false объявлений чужих регионов (Новосибирск/Казань/Челябинск/…),
# а ORDER BY listings_count DESC ставил самый мусорный адрес («Новосибирская
# обл.,Новосибирск», сотни listings) В НАЧАЛО — весь batch съедался мусором,
# который пользователь никогда не увидит (is_active=false), 8 ночных прогонов
# подряд saved=0. Активные объявления с валидным адресом почти всегда попадают
# в topN только теперь, когда мусор не конкурирует за место в LIMIT.
rows = (
db.execute(
text(
"""
SELECT address, city, COUNT(*) AS listings_count
FROM listings
WHERE lat IS NULL
AND is_active
AND address IS NOT NULL
AND length(trim(address)) >= 5
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - INTERVAL '7 days')
GROUP BY address, city
ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST
LIMIT :limit
"""
),
{"limit": batch_size},
)
.mappings()
.all()
)
result.addresses_total = len(rows)
if not rows:
logger.info("geocode_missing: 0 pending addresses — nothing to do")
result.duration_sec = time.monotonic() - start
return result
logger.info(
"geocode_missing: starting batch=%d total_pending_addresses=%d (top by listings count)",
batch_size,
result.addresses_total,
)
for idx, row in enumerate(rows):
# Дедлайн проверяется НА КАЖДОМ адресе, а не только между батчами (#3151).
# Раньше единственная проверка бюджета стояла в run-обёртке после возврата
# из этой функции, то есть потолок прогона на деле был «бюджет + один полный
# батч». Пока адрес стоил ~1.9 с это терялось в шуме; после общего
# ограничителя темпа Nominatim (#2953) средняя цена 4.5 с, а на трудном
# хвосте (tier-1 + до 4 typo-вариантов под паузой 1 с, плюс retry×3) — до
# 33 с. Прогон 5017 (27.08) при `budget_sec=1800` шёл 3150 с.
# Оборванный батч — штатный исход: `geocode_tried_at` проставлен только у
# обработанных пар, необработанные попадут в выборку следующего прогона.
if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
result.budget_exhausted = True
logger.info(
"geocode_missing: дедлайн исчерпан на %d/%d адресе — обрываю батч",
idx,
len(rows),
)
break
address: str = row["address"]
city: str | None = row.get("city")
listings_count: int = row["listings_count"]
result.addresses_processed += 1
try:
# known_city_hint (#2603) — общий гейт по словарю городов области для
# всех DB-колоночных callers. Для listings.city он сегодня no-op
# (скрапер пишет только шесть кураторских имён из
# scraper_kit CITY_DISPLAY_NAMES, все они есть в словаре), но держит
# инвариант единым с deals-путями, где колонка росреестровая и в
# хвосте лежит мусор. Сырой `city` ниже остаётся ключом группы для
# UPDATE — гейт влияет только на подсказку геокодеру.
geo = await geocode(address, db, city_hint=known_city_hint(city))
except Exception as exc:
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
result.addresses_failed += 1
if not dry_run:
# Пометить tried_at чтобы пара (address, city) не переотбиралась
# в следующих batch'ах этого же прогона (loop-safe backoff 7 дней).
# IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное
# `=` не поймает NULL-город и не должно задеть другой город с тем
# же текстом адреса.
# Намеренно БЕЗ `AND is_active` (#2604 п.2): tried_at — backoff-метка
# для (address, city) КАК ТЕКСТА, а не для конкретного listing.
# is_active=false дубликат этой пары и так никогда не будет выбран
# SELECT'ом заново (is_active=false исключён там навсегда) — фильтр
# здесь был бы no-op для неактивных строк. Единственный случай когда
# это имеет значение — если строка позже реактивируется (is_active
# → true): тогда tried_at уже стоит и backoff корректно защищает от
# немедленного повторного запроса того же заведомо неудачного адреса.
db.execute(
text(
"UPDATE listings SET geocode_tried_at = NOW()"
" WHERE address = :addr AND city IS NOT DISTINCT FROM :city"
" AND lat IS NULL"
),
{"addr": address, "city": city},
)
db.commit()
continue
if geo is None:
result.addresses_failed += 1
logger.info(
"geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)",
address[:60],
city,
listings_count,
)
if not dry_run:
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
# Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что
# и в except-ветке выше: backoff привязан к тексту (address, city),
# не к конкретному listing, is_active=false строка и так не выбирается
# SELECT'ом заново; при реактивации backoff корректно защитит от
# немедленного повтора заведомо неудачного запроса.
db.execute(
text(
"UPDATE listings SET geocode_tried_at = NOW()"
" WHERE address = :addr AND city IS NOT DISTINCT FROM :city"
" AND lat IS NULL"
),
{"addr": address, "city": city},
)
db.commit()
continue
if geo.provider == "cache":
result.cache_hits += 1
else:
result.cache_misses += 1
result.addresses_geocoded += 1
if dry_run:
# city в логе (#2603) — с #2594 это часть ключа группы: без него две
# строки dry-run с одинаковым текстом адреса неотличимы друг от друга.
logger.info(
"geocode_missing[dry]: '%s' city=%r → (%.5f, %.5f) provider=%s "
"would update %d listings",
address[:60],
city,
geo.lat,
geo.lon,
geo.provider,
listings_count,
)
continue
# Определяем точность геокода: city-centroid (нет номера дома) → 'city'.
# _geocode_is_coarse() проверяет confidence='locality' ИЛИ отсутствие
# house-number токена (1-3 цифры) в full_address — оба случая означают
# что геокодер не дошёл до дома и вернул центр НП/города (#769 Part E).
precision: str | None = "city" if _geocode_is_coarse(geo) else None
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
# geo_precision и geocode_tried_at проставляются одновременно с координатами.
# city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из
# которой был geocode-запрос; иначе тот же текст адреса в другом городе
# (city IS NULL или другой явный город) перезаписался бы чужими координатами.
#
# Намеренно БЕЗ `AND is_active` (#2604 п.1): координаты — свойство физического
# адреса, а не свойство конкретного объявления. Если у этой же пары
# (address, city) есть is_active=false дубликат с lat IS NULL, он получит те же
# координаты бесплатно — Nominatim-вызов уже оплачен геокодом активного
# листинга, доп. запроса не будет. SELECT выше и так навсегда исключает
# is_active=false строки из очереди — без этого UPDATE такой дубликат остался
# бы с NULL lat/lon НАВСЕГДА (переезд в EKB-only локальные реестры/analytics по
# координатам сломан для него), хотя ответ уже есть в руках. Единственный
# довод «за» фильтр — консистентность с SELECT — не перевешивает: это не
# ошибка данных (координаты адреса объективны и не зависят от активности),
# а чистый выигрыш (та же строка при реактивации уже готова, доп. cost = 0).
update_result = db.execute(
text(
"""
UPDATE listings
SET lat = :lat, lon = :lon, geo_precision = :precision,
geocode_tried_at = NOW()
WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL
"""
),
{
"lat": geo.lat,
"lon": geo.lon,
"precision": precision,
"addr": address,
"city": city,
},
)
db.commit()
result.listings_updated += update_result.rowcount
if (idx + 1) % 50 == 0:
elapsed = time.monotonic() - start
rate = (idx + 1) / elapsed if elapsed > 0 else 0
logger.info(
"geocode_missing: progress %d/%d "
"(geocoded=%d failed=%d cache_hits=%d listings_updated=%d) rate=%.1f addr/s",
idx + 1,
len(rows),
result.addresses_geocoded,
result.addresses_failed,
result.cache_hits,
result.listings_updated,
rate,
)
result.duration_sec = time.monotonic() - start
logger.info(
"geocode_missing: DONE batch=%d processed=%d geocoded=%d failed=%d "
"cache=(hit=%d miss=%d) listings_updated=%d duration=%.1fs",
batch_size,
result.addresses_processed,
result.addresses_geocoded,
result.addresses_failed,
result.cache_hits,
result.cache_misses,
result.listings_updated,
result.duration_sec,
)
return result
async def run_geocode_missing_listings(
db: Session,
*,
run_id: int,
params: dict,
) -> GeocodeBackfillResult:
"""Run-lifecycle wrapper: batch loop с wall-clock budget.
Запускается планировщиком (source='geocode_missing_listings') или вручную.
Гоняет geocode_missing_listings() в цикле до тех пор пока:
- res.addresses_total == 0 (нет pending адресов)
- res.addresses_total < batch_size (дренаж — последний batch меньше полного)
- истёк budget_sec
Params (из default_params jsonb в scrape_schedules):
batch_size: int — адресов за один вызов geocode_missing_listings (default 200).
budget_sec: float — максимальное время прогона в секундах (default 1800 = 30 мин).
Lifecycle:
update_heartbeat перед loop → аккумуляция counters → mark_done / mark_failed.
"""
batch_size = int(params.get("batch_size", 200))
budget_sec = float(params.get("budget_sec", 1800))
total = GeocodeBackfillResult()
counters: dict[str, int] = {
"checked": 0,
"saved": 0,
"skipped": 0,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
start = time.monotonic()
deadline = start + budget_sec
while True:
res = await geocode_missing_listings(
db, batch_size=batch_size, dry_run=False, deadline_monotonic=deadline
)
# Аккумулируем counters
total.addresses_total += res.addresses_total
total.addresses_processed += res.addresses_processed
total.addresses_geocoded += res.addresses_geocoded
total.addresses_failed += res.addresses_failed
total.listings_updated += res.listings_updated
total.cache_hits += res.cache_hits
total.cache_misses += res.cache_misses
counters = {
"checked": total.addresses_processed,
"saved": total.listings_updated,
"skipped": total.addresses_failed,
}
runs_mod.update_heartbeat(db, run_id, counters)
elapsed = time.monotonic() - start
if res.budget_exhausted:
# Батч оборван дедлайном внутри себя (#3151) — проверять дренаж
# по `addresses_total` уже нельзя: он считает ОТОБРАННЫЕ пары, а не
# обработанные, и на оборванном батче ничего не говорит об очереди.
logger.info(
"run_geocode_missing_listings: run_id=%d — бюджет %.0fs исчерпан "
"внутри батча (elapsed=%.1fs), завершаем",
run_id,
budget_sec,
elapsed,
)
break
if res.addresses_total == 0:
logger.info(
"run_geocode_missing_listings: run_id=%d — нет pending адресов, завершаем",
run_id,
)
break
if res.addresses_total < batch_size:
# #2604 п.3: с is_active-фильтром в SELECT очередь резко уже (была
# 14294 строк/98.5% мёртвых, стало ~220 активных → десятки уникальных
# пар address+city после GROUP BY) — этот дренаж почти всегда сработает
# уже на первой итерации (addresses_total < default batch_size=200), и
# это ПРАВИЛЬНОЕ поведение: разгребли всё что было, ждём следующего
# прогона. Никакого деления тут нет (только сравнение int), пустая
# очередь (addresses_total=0) ловится веткой выше, а не этой.
logger.info(
"run_geocode_missing_listings: run_id=%d — дренаж "
"(addresses_total=%d < batch_size=%d), завершаем",
run_id,
res.addresses_total,
batch_size,
)
break
if elapsed > budget_sec:
logger.info(
"run_geocode_missing_listings: run_id=%d — бюджет %.0fs исчерпан "
"(elapsed=%.1fs), завершаем",
run_id,
budget_sec,
elapsed,
)
break
total.duration_sec = time.monotonic() - start
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_geocode_missing_listings: run_id=%d DONE — "
"processed=%d geocoded=%d failed=%d listings_updated=%d duration=%.1fs",
run_id,
total.addresses_processed,
total.addresses_geocoded,
total.addresses_failed,
total.listings_updated,
total.duration_sec,
)
return total
except Exception as exc:
total.duration_sec = time.monotonic() - start
logger.exception(
"run_geocode_missing_listings: run_id=%d FAILED after %.1fs",
run_id,
total.duration_sec,
)
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise