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
`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
471 lines
26 KiB
Python
471 lines
26 KiB
Python
"""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
|