fix(tradein/scraper): сигнал живости из середины батча — живые прогоны перестают числиться зависшими (#2725)
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 3m9s
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 3m9s
reap_zombies меряет scrape_runs.heartbeat_at с порогом 6 ч, а cian_history_backfill слал heartbeat ровно один раз — ДО батча. Прогон, работающий дольше шести часов, помечался 'zombie' независимо от того, жив он или висит. Замер на проде 2026-08-06: 6 прогонов cian_history_backfill в статусе 'zombie', у всех шести сдвиг heartbeat 16-32 мс (единственный стартовый вызов) и финал ровно на started_at + 6.00 ч. Живыми они были: внутри окна пятерых писались строки offer_price_history с source='cian' (98/523/38/60/83 — плановый писатель этих строк только этот батч), у прогона 304 последняя строка легла через 5.40 ч после старта. Штатная длительность источника доходит до 5.06 ч (прогон 346). Чинится сигнал, а не критерий: пометка 'zombie' снимает running-блокировку источника (has_running_run), и ослабление критерия заперло бы источник на зависшем прогоне навсегда. Плюс mark_done апдейтит WHERE status='running' — после ложной пометки финал прогона становится no-op, отсюда нулевые counters у всех шести строк. backfill_cian_history и backfill_newbuilding_enrichment получают колбэк on_progress, дёргаемый на каждой сущности; планировщик переливает его в update_heartbeat вместе с текущими счётчиками (best-effort — сбой heartbeat не роняет уже идущую работу). newbuilding_enrich лечится тем же шаблоном превентивно: тот же единственный стартовый вызов, на проде пока max 1.08 ч при limit=25, но полный прогон — 318 домов по ~2.6 мин. Шаблон уже был в репозитории (house_imv_backfill, #1363) — здесь он доехал до двух оставшихся долгоживущих задач. listing_source_snapshot (34 зомби) в правку не входит: там вся работа — два set-based statement'а, heartbeat невозможен по построению, лечилось иначе в #2607 (statement_timeout). Refs #2725
This commit is contained in:
parent
6cf9172d96
commit
eec62c4e1c
5 changed files with 309 additions and 15 deletions
|
|
@ -76,7 +76,16 @@ async def _execute_cian_backfill(
|
||||||
"""Orchestrate Cian history backfill with heartbeat + checkpoint.
|
"""Orchestrate Cian history backfill with heartbeat + checkpoint.
|
||||||
|
|
||||||
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat)
|
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat)
|
||||||
before and after the batch call for zombie-detection visibility.
|
НА КАЖДОЙ сущности батча, а не только до и после него (#2725). Раньше сигнал
|
||||||
|
живости слался ровно один раз — до батча, — а `reap_zombies` меряет именно
|
||||||
|
heartbeat_at с порогом 6 ч, и добивал живые прогоны строго на 6-м часу: 6 прод-
|
||||||
|
прогонов этого источника помечены 'zombie' со сдвигом heartbeat 16-32 мс, при том
|
||||||
|
что у пятерых внутри окна писались строки offer_price_history (у прогона 304 — до
|
||||||
|
5.4 ч после старта), а штатная длительность источника доходит до 5.06 ч (346).
|
||||||
|
Цена ошибки не косметическая: mark_done апдейтит WHERE status='running', так что
|
||||||
|
после ложной пометки собственный финал прогона становится no-op (отсюда нулевые
|
||||||
|
counters у всех шести), а has_running_run перестаёт видеть прогон и следующий тик
|
||||||
|
может запустить второй такой же батч поверх работающего.
|
||||||
|
|
||||||
Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
|
Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
|
||||||
NULL via LEFT JOIN — so re-running after a partial completion naturally skips
|
NULL via LEFT JOIN — so re-running after a partial completion naturally skips
|
||||||
|
|
@ -85,9 +94,33 @@ async def _execute_cian_backfill(
|
||||||
Params (from default_params jsonb):
|
Params (from default_params jsonb):
|
||||||
batch_size: int — rows per run (listings + houses counted separately).
|
batch_size: int — rows per run (listings + houses counted separately).
|
||||||
"""
|
"""
|
||||||
from app.tasks.cian_history_backfill import backfill_cian_history
|
from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history
|
||||||
|
|
||||||
batch_size = int(params.get("batch_size", 100))
|
batch_size = int(params.get("batch_size", 100))
|
||||||
|
|
||||||
|
def _counters(result: CianBackfillResult) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"listings_processed": result.listings_processed,
|
||||||
|
"listings_succeeded": result.listings_succeeded,
|
||||||
|
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
||||||
|
"houses_processed": result.houses_processed,
|
||||||
|
"houses_succeeded": result.houses_succeeded,
|
||||||
|
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _heartbeat(progress: CianBackfillResult) -> None:
|
||||||
|
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
|
||||||
|
ронять уже идущую работу — прогон в худшем случае вернётся к прежнему
|
||||||
|
поведению (пометка 'zombie' на 6-м часу)."""
|
||||||
|
try:
|
||||||
|
runs_mod.update_heartbeat(db, run_id, _counters(progress))
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
|
||||||
|
run_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
"listings_processed": 0,
|
"listings_processed": 0,
|
||||||
"listings_succeeded": 0,
|
"listings_succeeded": 0,
|
||||||
|
|
@ -106,17 +139,10 @@ async def _execute_cian_backfill(
|
||||||
do_listings=True,
|
do_listings=True,
|
||||||
do_houses=True,
|
do_houses=True,
|
||||||
do_valuations=False,
|
do_valuations=False,
|
||||||
|
on_progress=_heartbeat,
|
||||||
)
|
)
|
||||||
|
|
||||||
counters = {
|
counters = {**_counters(result), "duration_sec": int(result.duration_sec)}
|
||||||
"listings_processed": result.listings_processed,
|
|
||||||
"listings_succeeded": result.listings_succeeded,
|
|
||||||
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
|
||||||
"houses_processed": result.houses_processed,
|
|
||||||
"houses_succeeded": result.houses_succeeded,
|
|
||||||
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
|
||||||
"duration_sec": int(result.duration_sec),
|
|
||||||
}
|
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
|
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@
|
||||||
Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column).
|
Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column).
|
||||||
|
|
||||||
Rate limit: scraper_settings.get_scraper_delay('cian') between requests.
|
Rate limit: scraper_settings.get_scraper_delay('cian') between requests.
|
||||||
|
|
||||||
|
Сигнал живости (#2725): батч дёргает `on_progress` на КАЖДОЙ сущности — caller
|
||||||
|
переливает это в scrape_runs.heartbeat_at. Пока колбэка не было, планировщик слал
|
||||||
|
heartbeat один раз ДО батча, а `reap_zombies` меряет ровно heartbeat с порогом 6 ч —
|
||||||
|
и добивал живые прогоны строго на 6-м часу (6 прод-прогонов, у пятерых внутри окна
|
||||||
|
писались строки offer_price_history, у одного — до 5.4 ч после старта). Ослаблять
|
||||||
|
критерий нельзя: пометка 'zombie' снимает running-блокировку источника
|
||||||
|
(`has_running_run`), без неё зависший прогон запер бы источник навсегда.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -25,6 +33,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
|
|
@ -68,6 +77,7 @@ async def backfill_cian_history(
|
||||||
do_houses: bool = True,
|
do_houses: bool = True,
|
||||||
do_valuations: bool = False,
|
do_valuations: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
on_progress: Callable[[CianBackfillResult], None] | None = None,
|
||||||
) -> CianBackfillResult:
|
) -> CianBackfillResult:
|
||||||
"""Iterate Cian listings + houses with missing history, fetch+save.
|
"""Iterate Cian listings + houses with missing history, fetch+save.
|
||||||
|
|
||||||
|
|
@ -82,6 +92,10 @@ async def backfill_cian_history(
|
||||||
do_valuations: process Cian Valuation Calculator batch (external_valuations backfill).
|
do_valuations: process Cian Valuation Calculator batch (external_valuations backfill).
|
||||||
Default False — opt-in because each call hits Cian auth-gated API.
|
Default False — opt-in because each call hits Cian auth-gated API.
|
||||||
dry_run: skip all fetch+save; only count and log pending rows.
|
dry_run: skip all fetch+save; only count and log pending rows.
|
||||||
|
on_progress: колбэк живости (#2725) — вызывается на каждой сущности ЛЮБОГО из
|
||||||
|
трёх блоков, до её обработки, с текущим (мутируемым) result. Caller пишет
|
||||||
|
heartbeat; исключения колбэка — на его совести (планировщик глушит их сам),
|
||||||
|
здесь они прервали бы батч.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
CianBackfillResult with per-domain counters + total wall-clock duration.
|
CianBackfillResult with per-domain counters + total wall-clock duration.
|
||||||
|
|
@ -120,6 +134,8 @@ async def backfill_cian_history(
|
||||||
listing_id: int = row["id"]
|
listing_id: int = row["id"]
|
||||||
source_url: str = row["source_url"]
|
source_url: str = row["source_url"]
|
||||||
result.listings_processed += 1
|
result.listings_processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
|
|
||||||
enrichment = None
|
enrichment = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -209,6 +225,8 @@ async def backfill_cian_history(
|
||||||
house_id: int = hrow["id"]
|
house_id: int = hrow["id"]
|
||||||
zhk_url: str = hrow["cian_zhk_url"]
|
zhk_url: str = hrow["cian_zhk_url"]
|
||||||
result.houses_processed += 1
|
result.houses_processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
|
|
||||||
enrichment = None
|
enrichment = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -291,6 +309,8 @@ async def backfill_cian_history(
|
||||||
else:
|
else:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
result.valuations_processed += 1
|
result.valuations_processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
try:
|
try:
|
||||||
cval = await estimate_via_cian_valuation(
|
cval = await estimate_via_cian_valuation(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field, fields
|
from dataclasses import dataclass, field, fields
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -306,8 +307,7 @@ def _house_enrichment_counts(db: Session, house_id: int) -> tuple[int, int, int]
|
||||||
rc = int(
|
rc = int(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT COUNT(*) FROM house_reliability_checks "
|
"SELECT COUNT(*) FROM house_reliability_checks WHERE house_id = CAST(:h AS bigint)"
|
||||||
"WHERE house_id = CAST(:h AS bigint)"
|
|
||||||
),
|
),
|
||||||
{"h": house_id},
|
{"h": house_id},
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
@ -328,6 +328,7 @@ async def backfill_newbuilding_enrichment(
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
request_delay_sec: float | None = None,
|
request_delay_sec: float | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
on_progress: Callable[[NewbuildingEnrichBackfillResult], None] | None = None,
|
||||||
) -> NewbuildingEnrichBackfillResult:
|
) -> NewbuildingEnrichBackfillResult:
|
||||||
"""Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses.
|
"""Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses.
|
||||||
|
|
||||||
|
|
@ -347,6 +348,11 @@ async def backfill_newbuilding_enrichment(
|
||||||
(default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing
|
(default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing
|
||||||
a resolve incurs TWO delays (resolve fetch + enrich fetch).
|
a resolve incurs TWO delays (resolve fetch + enrich fetch).
|
||||||
dry_run: count the population + log the pending list, fetch nothing, write nothing.
|
dry_run: count the population + log the pending list, fetch nothing, write nothing.
|
||||||
|
on_progress: колбэк живости (#2725) — вызывается на каждом доме с текущим
|
||||||
|
(мутируемым) result; caller пишет scrape_runs.heartbeat_at. Без него
|
||||||
|
heartbeat уходил один раз до цикла, а `reap_zombies` меряет именно его:
|
||||||
|
дом обходится за ~2.6 мин, и на limit'е порядка 140 (полный прогон — 318
|
||||||
|
домов, см. выше) прогон переваливал бы 6-часовой порог живым.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NewbuildingEnrichBackfillResult with population sizing, per-house outcome
|
NewbuildingEnrichBackfillResult with population sizing, per-house outcome
|
||||||
|
|
@ -415,6 +421,8 @@ async def backfill_newbuilding_enrichment(
|
||||||
zhk_url: str | None = row["cian_zhk_url"]
|
zhk_url: str | None = row["cian_zhk_url"]
|
||||||
ext_id: str | None = row["ext_id"]
|
ext_id: str | None = row["ext_id"]
|
||||||
result.processed += 1
|
result.processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
|
|
||||||
# Idempotency fast-path: with force=False the SELECT already excludes enriched
|
# Idempotency fast-path: with force=False the SELECT already excludes enriched
|
||||||
# houses (price_dynamics + reliability present), so this branch is a belt-and-
|
# houses (price_dynamics + reliability present), so this branch is a belt-and-
|
||||||
|
|
@ -696,6 +704,18 @@ async def run_newbuilding_enrich(
|
||||||
"failed_save": 0,
|
"failed_save": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _heartbeat(progress: NewbuildingEnrichBackfillResult) -> None:
|
||||||
|
"""Сигнал живости из середины цикла (#2725). Best-effort — сбой heartbeat не
|
||||||
|
должен ронять уже идущий обход."""
|
||||||
|
try:
|
||||||
|
runs_mod.update_heartbeat(db, run_id, progress.to_dict())
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"scheduler: newbuilding_enrich run_id=%d heartbeat failed (ignored)",
|
||||||
|
run_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
runs_mod.update_heartbeat(db, run_id, counters)
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
|
|
||||||
|
|
@ -704,6 +724,7 @@ async def run_newbuilding_enrich(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
force=force,
|
force=force,
|
||||||
request_delay_sec=request_delay_sec,
|
request_delay_sec=request_delay_sec,
|
||||||
|
on_progress=_heartbeat,
|
||||||
)
|
)
|
||||||
|
|
||||||
counters = result.to_dict()
|
counters = result.to_dict()
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,8 @@ async def test_run_wrapper_marks_done_and_passes_params(monkeypatch: pytest.Monk
|
||||||
"""run_newbuilding_enrich emits heartbeat → delegates with parsed params → mark_done."""
|
"""run_newbuilding_enrich emits heartbeat → delegates with parsed params → mark_done."""
|
||||||
seen: dict = {}
|
seen: dict = {}
|
||||||
|
|
||||||
async def _fake_backfill(_db, *, limit, force, request_delay_sec):
|
async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None):
|
||||||
|
# on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем.
|
||||||
seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec)
|
seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec)
|
||||||
return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_rows=2)
|
return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_rows=2)
|
||||||
|
|
||||||
|
|
@ -575,7 +576,8 @@ async def test_run_wrapper_defaults_when_params_empty(monkeypatch: pytest.Monkey
|
||||||
"""Empty default_params → limit=25, force=False, request_delay_sec=None (→ scraper delay)."""
|
"""Empty default_params → limit=25, force=False, request_delay_sec=None (→ scraper delay)."""
|
||||||
seen: dict = {}
|
seen: dict = {}
|
||||||
|
|
||||||
async def _fake_backfill(_db, *, limit, force, request_delay_sec):
|
async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None):
|
||||||
|
# on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем.
|
||||||
seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec)
|
seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec)
|
||||||
return NewbuildingEnrichBackfillResult()
|
return NewbuildingEnrichBackfillResult()
|
||||||
|
|
||||||
|
|
|
||||||
225
tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py
Normal file
225
tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"""#2725: сигнал живости слался один раз — до батча, — и живые прогоны reap'ились.
|
||||||
|
|
||||||
|
Что было. `_execute_cian_backfill` дёргал `update_heartbeat` ровно один раз, ДО
|
||||||
|
`backfill_cian_history()`, а сам батч (до 100 объявлений + 37 домов, каждое — fetch
|
||||||
|
через браузер + пауза ~5 с) heartbeat не трогал. `reap_zombies` меряет именно
|
||||||
|
`heartbeat_at` с порогом ZOMBIE_THRESHOLD_HOURS = 6 ч → прогон помечался 'zombie'
|
||||||
|
строго на 6-м часу независимо от того, работает он или висит.
|
||||||
|
|
||||||
|
Прод-замер 2026-08-06: 6 прогонов `cian_history_backfill` со статусом 'zombie', у всех
|
||||||
|
шести сдвиг heartbeat 16-32 мс (= единственный стартовый вызов) и финал ровно на
|
||||||
|
started_at + 6.00 ч. Живыми они при этом были: внутри окна пятерых писались строки
|
||||||
|
offer_price_history с source='cian' (98/523/38/60/83 — плановый писатель этих строк
|
||||||
|
только этот батч), у прогона 304 последняя строка легла через 5.40 ч после старта.
|
||||||
|
Штатная длительность источника доходит до 5.06 ч (прогон 346, counters.duration_sec
|
||||||
|
18230) — то есть источник ходит вплотную к порогу.
|
||||||
|
|
||||||
|
Почему чинится сигнал, а не критерий: пометка 'zombie' снимает running-блокировку
|
||||||
|
источника (`has_running_run` видит только status='running'), и без неё зависший
|
||||||
|
прогон запер бы источник навсегда. Плюс `mark_done` апдейтит `WHERE status='running'`,
|
||||||
|
поэтому после ложной пометки собственный финал прогона — no-op (отсюда нулевые
|
||||||
|
counters у всех шести строк).
|
||||||
|
|
||||||
|
Фальсификация: на старом коде тест 2 падает — планировщик не передавал `on_progress`,
|
||||||
|
батч heartbeat не двигал, и к концу 7-часовой работы возраст сигнала = 7 ч > 6 ч.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.orchestration.scheduler import ZOMBIE_THRESHOLD_HOURS
|
||||||
|
|
||||||
|
from app.services import scheduler as sched_mod
|
||||||
|
from app.tasks import cian_history_backfill
|
||||||
|
|
||||||
|
|
||||||
|
def _would_be_reaped(heartbeat_at: datetime, now: datetime) -> bool:
|
||||||
|
"""Критерий reap_zombies дословно: heartbeat старше порога → 'zombie'."""
|
||||||
|
return heartbeat_at < now - timedelta(hours=ZOMBIE_THRESHOLD_HOURS)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBrowserFetcher:
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _FakeBrowserFetcher:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Батч сообщает о продвижении на каждой сущности ────────────────────────
|
||||||
|
async def test_batch_reports_progress_per_entity() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.mappings.return_value.all.side_effect = [
|
||||||
|
[
|
||||||
|
{"id": 1, "source_url": "https://cian.ru/1"},
|
||||||
|
{"id": 2, "source_url": "https://cian.ru/2"},
|
||||||
|
],
|
||||||
|
[{"id": 10, "cian_zhk_url": "https://cian.ru/zhk-10"}],
|
||||||
|
]
|
||||||
|
seen: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
|
||||||
|
patch.object(
|
||||||
|
cian_history_backfill,
|
||||||
|
"fetch_detail",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(price_changes=[])),
|
||||||
|
),
|
||||||
|
patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()),
|
||||||
|
patch(
|
||||||
|
"scraper_kit.providers.cian.newbuilding.fetch_newbuilding",
|
||||||
|
AsyncMock(return_value=SimpleNamespace()),
|
||||||
|
),
|
||||||
|
patch("scraper_kit.providers.cian.newbuilding.save_newbuilding_enrichment", MagicMock()),
|
||||||
|
patch("asyncio.sleep", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
result = await cian_history_backfill.backfill_cian_history(
|
||||||
|
db,
|
||||||
|
do_listings=True,
|
||||||
|
do_houses=True,
|
||||||
|
do_valuations=False,
|
||||||
|
on_progress=lambda r: seen.append((r.listings_processed, r.houses_processed)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# По одному сигналу на каждую сущность обоих блоков, счётчики растут.
|
||||||
|
assert seen == [(1, 0), (2, 0), (2, 1)]
|
||||||
|
assert result.listings_processed == 2
|
||||||
|
assert result.houses_processed == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Долгий прогон с продвигающимся heartbeat не помечается зависшим ───────
|
||||||
|
async def test_long_run_with_advancing_heartbeat_is_not_reaped() -> None:
|
||||||
|
"""7 часов работы, час на сущность: планировщик обязан двигать heartbeat."""
|
||||||
|
t0 = datetime(2026, 6, 26, 4, 47, tzinfo=UTC)
|
||||||
|
clock = SimpleNamespace(now=t0)
|
||||||
|
beats: list[datetime] = []
|
||||||
|
|
||||||
|
async def _fake_batch(db: Any, **kwargs: Any) -> Any:
|
||||||
|
on_progress = kwargs.get("on_progress")
|
||||||
|
result = cian_history_backfill.CianBackfillResult()
|
||||||
|
for _ in range(7): # 7 сущностей по часу — дольше 6-часового порога
|
||||||
|
clock.now += timedelta(hours=1)
|
||||||
|
result.listings_processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
|
result.duration_sec = 7 * 3600
|
||||||
|
return result
|
||||||
|
|
||||||
|
fake_runs = SimpleNamespace(
|
||||||
|
update_heartbeat=lambda db, run_id, counters: beats.append(clock.now),
|
||||||
|
mark_done=MagicMock(),
|
||||||
|
mark_failed=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sched_mod, "runs_mod", fake_runs),
|
||||||
|
patch.object(cian_history_backfill, "backfill_cian_history", _fake_batch),
|
||||||
|
):
|
||||||
|
await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={})
|
||||||
|
|
||||||
|
assert len(beats) == 8, "стартовый сигнал + по одному на сущность"
|
||||||
|
reaped = _would_be_reaped(beats[-1], clock.now)
|
||||||
|
assert not reaped, "живой прогон с продвигающимся heartbeat не должен reap'иться"
|
||||||
|
assert fake_runs.mark_done.called
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Прогон без продвижения — помечается (контроль критерия) ───────────────
|
||||||
|
async def test_long_run_without_advancing_heartbeat_is_reaped() -> None:
|
||||||
|
"""Тот же прогон, но батч сигнала не шлёт — критерий обязан сработать."""
|
||||||
|
t0 = datetime(2026, 6, 26, 4, 47, tzinfo=UTC)
|
||||||
|
clock = SimpleNamespace(now=t0)
|
||||||
|
beats: list[datetime] = []
|
||||||
|
|
||||||
|
async def _mute_batch(db: Any, **kwargs: Any) -> Any:
|
||||||
|
clock.now += timedelta(hours=7) # работает, но молча
|
||||||
|
return cian_history_backfill.CianBackfillResult()
|
||||||
|
|
||||||
|
fake_runs = SimpleNamespace(
|
||||||
|
update_heartbeat=lambda db, run_id, counters: beats.append(clock.now),
|
||||||
|
mark_done=MagicMock(),
|
||||||
|
mark_failed=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sched_mod, "runs_mod", fake_runs),
|
||||||
|
patch.object(cian_history_backfill, "backfill_cian_history", _mute_batch),
|
||||||
|
):
|
||||||
|
await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={})
|
||||||
|
|
||||||
|
assert beats == [t0], "единственный сигнал — стартовый"
|
||||||
|
assert _would_be_reaped(beats[-1], clock.now)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Сбой heartbeat не роняет уже идущую работу ────────────────────────────
|
||||||
|
async def test_heartbeat_failure_does_not_abort_the_batch() -> None:
|
||||||
|
processed: list[int] = []
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def _flaky_heartbeat(db: Any, run_id: int, counters: dict[str, int]) -> None:
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] > 1: # стартовый прошёл, дальше БД отвалилась
|
||||||
|
raise Exception("DB gone")
|
||||||
|
|
||||||
|
async def _fake_batch(db: Any, **kwargs: Any) -> Any:
|
||||||
|
on_progress = kwargs["on_progress"]
|
||||||
|
result = cian_history_backfill.CianBackfillResult()
|
||||||
|
for _ in range(3):
|
||||||
|
result.listings_processed += 1
|
||||||
|
on_progress(result) # обязан проглотить исключение внутри себя
|
||||||
|
processed.append(result.listings_processed)
|
||||||
|
return result
|
||||||
|
|
||||||
|
fake_runs = SimpleNamespace(
|
||||||
|
update_heartbeat=_flaky_heartbeat,
|
||||||
|
mark_done=MagicMock(),
|
||||||
|
mark_failed=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sched_mod, "runs_mod", fake_runs),
|
||||||
|
patch.object(cian_history_backfill, "backfill_cian_history", _fake_batch),
|
||||||
|
):
|
||||||
|
await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={})
|
||||||
|
|
||||||
|
assert processed == [1, 2, 3]
|
||||||
|
assert fake_runs.mark_done.called
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. Тот же дефект у newbuilding_enrich — сигнал прокинут ──────────────────
|
||||||
|
async def test_newbuilding_enrich_passes_progress_callback() -> None:
|
||||||
|
from app.tasks import newbuilding_enrich_backfill as nb
|
||||||
|
|
||||||
|
beats: list[dict[str, int]] = []
|
||||||
|
|
||||||
|
async def _fake_backfill(db: Any, **kwargs: Any) -> Any:
|
||||||
|
on_progress = kwargs.get("on_progress")
|
||||||
|
assert on_progress is not None, "планировщик обязан прокинуть сигнал живости"
|
||||||
|
result = nb.NewbuildingEnrichBackfillResult()
|
||||||
|
result.processed += 1
|
||||||
|
on_progress(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
fake_runs = SimpleNamespace(
|
||||||
|
update_heartbeat=lambda db, run_id, counters: beats.append(counters),
|
||||||
|
mark_done=MagicMock(),
|
||||||
|
mark_failed=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(nb, "runs_mod", fake_runs),
|
||||||
|
patch.object(nb, "backfill_newbuilding_enrichment", _fake_backfill),
|
||||||
|
):
|
||||||
|
await nb.run_newbuilding_enrich(MagicMock(), run_id=1, params={})
|
||||||
|
|
||||||
|
assert len(beats) == 2, "стартовый сигнал + сигнал из середины цикла"
|
||||||
|
assert beats[-1]["processed"] == 1
|
||||||
Loading…
Add table
Reference in a new issue