Compare commits
No commits in common. "6868d489aade42b7a19e2cc4d101760aca91949f" and "72d0ebf53ff9e53b0fa20502b36d6560d5f35d71" have entirely different histories.
6868d489aa
...
72d0ebf53f
2 changed files with 3 additions and 165 deletions
|
|
@ -12,14 +12,6 @@ Pattern: dedup по паре (address, city) — 1 уникальная пара
|
||||||
не схлопываться в один geocode-вызов и один UPDATE по тексту адреса).
|
не схлопываться в один geocode-вызов и один UPDATE по тексту адреса).
|
||||||
Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
|
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):
|
Отличие от /admin/geocode-missing (per-ID):
|
||||||
- Этот модуль группирует по (address, city) → меньше API calls (dedup), но не
|
- Этот модуль группирует по (address, city) → меньше API calls (dedup), но не
|
||||||
схлопывает разные города с одинаковым текстом адреса.
|
схлопывает разные города с одинаковым текстом адреса.
|
||||||
|
|
@ -67,14 +59,11 @@ async def geocode_missing_listings(
|
||||||
"""Geocode listings с NULL coords (любой source).
|
"""Geocode listings с NULL coords (любой source).
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active
|
1. SELECT address, city FROM listings WHERE lat IS NULL AND address IS NOT NULL
|
||||||
AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC
|
GROUP BY address, city ORDER BY COUNT(*) DESC LIMIT batch_size
|
||||||
LIMIT batch_size
|
|
||||||
(приоритет парам address+city с большим числом listings — больший ROI per
|
(приоритет парам address+city с большим числом listings — больший ROI per
|
||||||
geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3:
|
geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3:
|
||||||
один и тот же текст адреса в разных городах — разные записи. `is_active` —
|
один и тот же текст адреса в разных городах — разные записи)
|
||||||
#2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда
|
|
||||||
не попадут в выдачу пользователю)
|
|
||||||
|
|
||||||
2. Для каждой пары (address, city):
|
2. Для каждой пары (address, city):
|
||||||
- geocode(address, db, city_hint=city) — auto-cache (hit или miss)
|
- geocode(address, db, city_hint=city) — auto-cache (hit или miss)
|
||||||
|
|
@ -109,14 +98,6 @@ async def geocode_missing_listings(
|
||||||
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
|
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
|
||||||
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
|
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
|
||||||
# failed-пары не переотбираются бесконечно.
|
# 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 = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -124,7 +105,6 @@ async def geocode_missing_listings(
|
||||||
SELECT address, city, COUNT(*) AS listings_count
|
SELECT address, city, COUNT(*) AS listings_count
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND is_active
|
|
||||||
AND address IS NOT NULL
|
AND address IS NOT NULL
|
||||||
AND length(trim(address)) >= 5
|
AND length(trim(address)) >= 5
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
|
|
@ -170,14 +150,6 @@ async def geocode_missing_listings(
|
||||||
# IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное
|
# IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное
|
||||||
# `=` не поймает 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(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE listings SET geocode_tried_at = NOW()"
|
"UPDATE listings SET geocode_tried_at = NOW()"
|
||||||
|
|
@ -199,11 +171,6 @@ async def geocode_missing_listings(
|
||||||
)
|
)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
|
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
|
||||||
# Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что
|
|
||||||
# и в except-ветке выше: backoff привязан к тексту (address, city),
|
|
||||||
# не к конкретному listing, is_active=false строка и так не выбирается
|
|
||||||
# SELECT'ом заново; при реактивации backoff корректно защитит от
|
|
||||||
# немедленного повтора заведомо неудачного запроса.
|
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE listings SET geocode_tried_at = NOW()"
|
"UPDATE listings SET geocode_tried_at = NOW()"
|
||||||
|
|
@ -244,18 +211,6 @@ async def geocode_missing_listings(
|
||||||
# city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из
|
# city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из
|
||||||
# которой был geocode-запрос; иначе тот же текст адреса в другом городе
|
# которой был geocode-запрос; иначе тот же текст адреса в другом городе
|
||||||
# (city IS NULL или другой явный город) перезаписался бы чужими координатами.
|
# (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(
|
update_result = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -372,13 +327,6 @@ async def run_geocode_missing_listings(
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
if res.addresses_total < batch_size:
|
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(
|
logger.info(
|
||||||
"run_geocode_missing_listings: run_id=%d — дренаж "
|
"run_geocode_missing_listings: run_id=%d — дренаж "
|
||||||
"(addresses_total=%d < batch_size=%d), завершаем",
|
"(addresses_total=%d < batch_size=%d), завершаем",
|
||||||
|
|
|
||||||
|
|
@ -342,31 +342,6 @@ async def test_geocode_missing_recent_tried_at_excluded_via_where() -> None:
|
||||||
assert "7 days" in sql_text
|
assert "7 days" in sql_text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_geocode_missing_select_filters_is_active() -> None:
|
|
||||||
"""SELECT содержит `AND is_active` (#2604 п.1).
|
|
||||||
|
|
||||||
На проде очередь без этого фильтра была на 98.5% забита is_active=false
|
|
||||||
объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без улицы и дома;
|
|
||||||
`ORDER BY listings_count DESC` ставил самый мусорный адрес («Новосибирская
|
|
||||||
обл.,Новосибирск», 214 listings) В НАЧАЛО очереди — весь Nominatim-бюджет
|
|
||||||
(1 req/sec) съедался мусором, до реальных активных адресов дело не доходило
|
|
||||||
(8 ночных прогонов подряд: saved=0). Falsification-проба: на коде ДО фикса
|
|
||||||
`"AND is_active" in sql_text` ложно, тест падает; после фикса проходит.
|
|
||||||
"""
|
|
||||||
db = MagicMock()
|
|
||||||
select_result = MagicMock()
|
|
||||||
select_result.mappings.return_value.all.return_value = []
|
|
||||||
db.execute.return_value = select_result
|
|
||||||
|
|
||||||
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock):
|
|
||||||
await geocode_missing_listings(db, batch_size=10)
|
|
||||||
|
|
||||||
first_call = db.execute.call_args_list[0]
|
|
||||||
sql_text = str(first_call[0][0])
|
|
||||||
assert "AND is_active" in sql_text
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_geocode_missing_listings_terminates_on_drained() -> None:
|
async def test_run_geocode_missing_listings_terminates_on_drained() -> None:
|
||||||
"""run_geocode_missing_listings завершается когда addresses_total == 0 (ничего pending)."""
|
"""run_geocode_missing_listings завершается когда addresses_total == 0 (ничего pending)."""
|
||||||
|
|
@ -585,91 +560,6 @@ async def test_geocode_missing_failed_pair_tried_at_update_scoped_to_city() -> N
|
||||||
assert params["city"] == "Нижний Тагил"
|
assert params["city"] == "Нижний Тагил"
|
||||||
|
|
||||||
|
|
||||||
# ── #2604 п.1/п.2: UPDATE decisions — locked in by test, not just comment ────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_geocode_missing_success_update_not_filtered_by_is_active() -> None:
|
|
||||||
"""Decision #2604 п.1 (UPDATE lat/lon): намеренно БЕЗ `is_active` в WHERE.
|
|
||||||
|
|
||||||
Координаты — свойство физического адреса (address, city), не свойство
|
|
||||||
конкретного listing. is_active=false дубликат ЭТОЙ ЖЕ пары никогда не будет
|
|
||||||
независимо отобран SELECT'ом (он навсегда исключён оттуда) — без unfiltered
|
|
||||||
UPDATE такой дубликат остался бы с NULL lat/lon навсегда, хотя ответ уже
|
|
||||||
получен и оплачен Nominatim-вызовом активного листинга.
|
|
||||||
"""
|
|
||||||
rows = [{"address": "ул. Тестовая, 1", "city": "Екатеринбург", "listings_count": 2}]
|
|
||||||
db = MagicMock()
|
|
||||||
select_result = MagicMock()
|
|
||||||
select_result.mappings.return_value.all.return_value = rows
|
|
||||||
update_result = MagicMock()
|
|
||||||
update_result.rowcount = 2
|
|
||||||
db.execute.side_effect = [select_result, update_result]
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"app.tasks.geocode_missing.geocode",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=_make_geocode_result("nominatim"),
|
|
||||||
):
|
|
||||||
await geocode_missing_listings(db, batch_size=200)
|
|
||||||
|
|
||||||
update_call = db.execute.call_args_list[1]
|
|
||||||
sql = str(update_call.args[0])
|
|
||||||
assert "is_active" not in sql
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_geocode_missing_notfound_tried_at_update_not_filtered_by_is_active() -> None:
|
|
||||||
"""Decision #2604 п.2 (geo is None → tried_at UPDATE): намеренно БЕЗ `is_active`.
|
|
||||||
|
|
||||||
tried_at — backoff-метка для (address, city) КАК ТЕКСТА, не для конкретного
|
|
||||||
listing; is_active=false дубликат и так никогда не переотбирается SELECT'ом.
|
|
||||||
Единственный сценарий где это важно — реактивация (is_active → true) той же
|
|
||||||
строки: backoff уже стоит и корректно защищает от немедленного повтора
|
|
||||||
заведомо неудачного адреса.
|
|
||||||
"""
|
|
||||||
rows = [{"address": "несуществующий адрес", "city": None, "listings_count": 1}]
|
|
||||||
db = MagicMock()
|
|
||||||
select_result = MagicMock()
|
|
||||||
select_result.mappings.return_value.all.return_value = rows
|
|
||||||
tried_at_result = MagicMock()
|
|
||||||
db.execute.side_effect = [select_result, tried_at_result]
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"app.tasks.geocode_missing.geocode",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
await geocode_missing_listings(db, batch_size=200)
|
|
||||||
|
|
||||||
update_call = db.execute.call_args_list[1]
|
|
||||||
sql = str(update_call.args[0])
|
|
||||||
assert "is_active" not in sql
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_geocode_missing_exception_tried_at_update_not_filtered_by_is_active() -> None:
|
|
||||||
"""Decision #2604 п.2 (geocode() raises → tried_at UPDATE): та же логика, что и
|
|
||||||
в NOT-FOUND ветке выше — намеренно БЕЗ `is_active`, зафиксировано тестом."""
|
|
||||||
rows = [{"address": "ул. Битая, 99", "city": None, "listings_count": 1}]
|
|
||||||
db = MagicMock()
|
|
||||||
select_result = MagicMock()
|
|
||||||
select_result.mappings.return_value.all.return_value = rows
|
|
||||||
tried_at_result = MagicMock()
|
|
||||||
db.execute.side_effect = [select_result, tried_at_result]
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"app.tasks.geocode_missing.geocode",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
side_effect=RuntimeError("timeout"),
|
|
||||||
):
|
|
||||||
await geocode_missing_listings(db, batch_size=200)
|
|
||||||
|
|
||||||
update_call = db.execute.call_args_list[1]
|
|
||||||
sql = str(update_call.args[0])
|
|
||||||
assert "is_active" not in sql
|
|
||||||
|
|
||||||
|
|
||||||
# ── Integration-style: estimator Avito exclusion removed ─────────────────────
|
# ── Integration-style: estimator Avito exclusion removed ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue