perf(cadastre): freshness skip + grid-walk heartbeat + log noise reduction #187
4 changed files with 359 additions and 12 deletions
|
|
@ -218,6 +218,7 @@ async def harvest_quarter(
|
||||||
client=client,
|
client=client,
|
||||||
quarter=quarter,
|
quarter=quarter,
|
||||||
layer_id=cat_id,
|
layer_id=cat_id,
|
||||||
|
update_progress=update_progress,
|
||||||
)
|
)
|
||||||
result.grid_walk_requests += n_requests
|
result.grid_walk_requests += n_requests
|
||||||
|
|
||||||
|
|
@ -276,12 +277,19 @@ async def _grid_walk_category(
|
||||||
layer_id: int,
|
layer_id: int,
|
||||||
grid_size: int = 15,
|
grid_size: int = 15,
|
||||||
tile_size: int = 512,
|
tile_size: int = 512,
|
||||||
|
update_progress: Callable[[dict[str, Any]], None] | None = None,
|
||||||
|
heartbeat_every: int = 50,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Grid-walk layer_id в bbox квартала.
|
"""Grid-walk layer_id в bbox квартала.
|
||||||
|
|
||||||
Генерирует grid_size×grid_size ячеек, для каждой делает wms_feature_info.
|
Генерирует grid_size×grid_size ячеек, для каждой делает wms_feature_info.
|
||||||
Дедуплицирует по cad_num, upsert'ит новые объекты.
|
Дедуплицирует по cad_num, upsert'ит новые объекты.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
update_progress: Fix I — callback для heartbeat внутри grid-walk loop.
|
||||||
|
225 cells × ~70ms = ~15-75s без heartbeat, zombie-cleanup ложно
|
||||||
|
помечал worker как мёртвого. Вызываем каждые heartbeat_every cells.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(upserted_count, requests_count)
|
(upserted_count, requests_count)
|
||||||
"""
|
"""
|
||||||
|
|
@ -300,7 +308,19 @@ async def _grid_walk_category(
|
||||||
upserted = 0
|
upserted = 0
|
||||||
requests = 0
|
requests = 0
|
||||||
|
|
||||||
for cell_bbox, click_xy in grid_points:
|
for idx, (cell_bbox, click_xy) in enumerate(grid_points):
|
||||||
|
# Fix I: heartbeat каждые N cells — обновляет cadastre_jobs.heartbeat_at
|
||||||
|
# чтобы cleanup_zombies (5min stale check) не cancel'нул живой worker.
|
||||||
|
if update_progress is not None and idx > 0 and idx % heartbeat_every == 0:
|
||||||
|
update_progress(
|
||||||
|
{
|
||||||
|
"phase": f"grid_walk_{layer_id}_cell_{idx}",
|
||||||
|
"quarter": quarter,
|
||||||
|
"grid_cells_done": idx,
|
||||||
|
"grid_cells_total": len(grid_points),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
features = await client.wms_feature_info(
|
features = await client.wms_feature_info(
|
||||||
layer_id=layer_id,
|
layer_id=layer_id,
|
||||||
|
|
@ -311,7 +331,10 @@ async def _grid_walk_category(
|
||||||
)
|
)
|
||||||
requests += 1
|
requests += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
# Fix J: NSPD WMS возвращает HTTP 500 на десятки cells per quarter —
|
||||||
|
# это server-side noise, не наш bug. logger.debug чтобы не засорять
|
||||||
|
# prod логи (раньше было warning → 200+ warnings per quarter).
|
||||||
|
logger.debug(
|
||||||
"_grid_walk_category: wms_feature_info error layer=%d quarter=%s: %s",
|
"_grid_walk_category: wms_feature_info error layer=%d quarter=%s: %s",
|
||||||
layer_id,
|
layer_id,
|
||||||
quarter,
|
quarter,
|
||||||
|
|
@ -500,7 +523,7 @@ def upsert_parcel(db: Session, feature: NSPDBulkFeature, source: str = "search")
|
||||||
"readable_address": raw_opts.get("readable_address"),
|
"readable_address": raw_opts.get("readable_address"),
|
||||||
"status": raw_opts.get("status"),
|
"status": raw_opts.get("status"),
|
||||||
"previously_posted": raw_opts.get("previously_posted"),
|
"previously_posted": raw_opts.get("previously_posted"),
|
||||||
"registration_date": raw_opts.get("registration_date"),
|
"registration_date": _parse_nspd_date(raw_opts.get("registration_date")),
|
||||||
"subcategory": _safe_int(raw_opts.get("subcategory")),
|
"subcategory": _safe_int(raw_opts.get("subcategory")),
|
||||||
"cadastral_districts_code": _safe_int(raw_opts.get("cadastral_districts_code")),
|
"cadastral_districts_code": _safe_int(raw_opts.get("cadastral_districts_code")),
|
||||||
"geom": geom_json,
|
"geom": geom_json,
|
||||||
|
|
@ -615,8 +638,10 @@ def upsert_building(db: Session, feature: NSPDBulkFeature, source: str = "search
|
||||||
"cost_index": _safe_numeric(raw_opts.get("cost_index")),
|
"cost_index": _safe_numeric(raw_opts.get("cost_index")),
|
||||||
"year_built": _safe_int(raw_opts.get("year_built")),
|
"year_built": _safe_int(raw_opts.get("year_built")),
|
||||||
"year_commisioning": _safe_int(raw_opts.get("year_commisioning")),
|
"year_commisioning": _safe_int(raw_opts.get("year_commisioning")),
|
||||||
"build_record_registration_date": raw_opts.get("build_record_registration_date"),
|
"build_record_registration_date": _parse_nspd_date(
|
||||||
"registration_date": raw_opts.get("registration_date"),
|
raw_opts.get("build_record_registration_date")
|
||||||
|
),
|
||||||
|
"registration_date": _parse_nspd_date(raw_opts.get("registration_date")),
|
||||||
"ownership_type": raw_opts.get("ownership_type"),
|
"ownership_type": raw_opts.get("ownership_type"),
|
||||||
"cultural_heritage_object": cultural_heritage_bool,
|
"cultural_heritage_object": cultural_heritage_bool,
|
||||||
"cultural_heritage_val": ch_str,
|
"cultural_heritage_val": ch_str,
|
||||||
|
|
@ -746,7 +771,7 @@ def upsert_construction(db: Session, feature: NSPDBulkFeature, source: str = "se
|
||||||
"address_readable_address": raw_opts.get("address_readable_address"),
|
"address_readable_address": raw_opts.get("address_readable_address"),
|
||||||
"status": raw_opts.get("status"),
|
"status": raw_opts.get("status"),
|
||||||
"common_data_status": raw_opts.get("common_data_status"),
|
"common_data_status": raw_opts.get("common_data_status"),
|
||||||
"registration_date": raw_opts.get("registration_date"),
|
"registration_date": _parse_nspd_date(raw_opts.get("registration_date")),
|
||||||
"registers_id": _safe_int(raw_opts.get("registers_id")),
|
"registers_id": _safe_int(raw_opts.get("registers_id")),
|
||||||
"united_cad_number": raw_opts.get("united_cad_number"),
|
"united_cad_number": raw_opts.get("united_cad_number"),
|
||||||
"geom": geom_json,
|
"geom": geom_json,
|
||||||
|
|
@ -870,7 +895,7 @@ def upsert_enk(db: Session, feature: NSPDBulkFeature, source: str = "search") ->
|
||||||
"cost_value": _safe_numeric(raw_opts.get("cost_value")),
|
"cost_value": _safe_numeric(raw_opts.get("cost_value")),
|
||||||
"facility_cad_number": raw_opts.get("facility_cad_number"),
|
"facility_cad_number": raw_opts.get("facility_cad_number"),
|
||||||
"readable_address": raw_opts.get("readable_address"),
|
"readable_address": raw_opts.get("readable_address"),
|
||||||
"registration_date": raw_opts.get("registration_date"),
|
"registration_date": _parse_nspd_date(raw_opts.get("registration_date")),
|
||||||
"registers_id": _safe_int(raw_opts.get("registers_id")),
|
"registers_id": _safe_int(raw_opts.get("registers_id")),
|
||||||
"geom": geom_json,
|
"geom": geom_json,
|
||||||
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
||||||
|
|
@ -973,10 +998,10 @@ def upsert_zouit(db: Session, feature: NSPDBulkFeature, source: str = "search")
|
||||||
"content_restrict_encumbrances": raw_opts.get("content_restrict_encumbrances"),
|
"content_restrict_encumbrances": raw_opts.get("content_restrict_encumbrances"),
|
||||||
"name_by_doc": raw_opts.get("name_by_doc"),
|
"name_by_doc": raw_opts.get("name_by_doc"),
|
||||||
"legal_act_name": raw_opts.get("legal_act_name"),
|
"legal_act_name": raw_opts.get("legal_act_name"),
|
||||||
"legal_act_date": raw_opts.get("legal_act_date"),
|
"legal_act_date": _parse_nspd_date(raw_opts.get("legal_act_date")),
|
||||||
"legal_act_number": raw_opts.get("legal_act_number"),
|
"legal_act_number": raw_opts.get("legal_act_number"),
|
||||||
"legal_act_issuer": raw_opts.get("legal_act_issuer"),
|
"legal_act_issuer": raw_opts.get("legal_act_issuer"),
|
||||||
"registration_date": raw_opts.get("registration_date"),
|
"registration_date": _parse_nspd_date(raw_opts.get("registration_date")),
|
||||||
"old_account_number": raw_opts.get("old_account_number"),
|
"old_account_number": raw_opts.get("old_account_number"),
|
||||||
"geom": geom_json,
|
"geom": geom_json,
|
||||||
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
||||||
|
|
@ -1180,3 +1205,42 @@ def _safe_int(value: Any) -> int | None:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_nspd_date(value: Any) -> str | None:
|
||||||
|
"""Конвертировать NSPD date в ISO YYYY-MM-DD string.
|
||||||
|
|
||||||
|
NSPD возвращает даты в смешанных форматах:
|
||||||
|
- '13.03.2023' (DD.MM.YYYY — для Сооружения/ОНС/ЕНК)
|
||||||
|
- '2023-03-13' (ISO — для ЗУ/Здания)
|
||||||
|
- '2023-03-13 00:00:00' (ISO with time)
|
||||||
|
- None / empty string
|
||||||
|
|
||||||
|
PostgreSQL DATE cast принимает ISO. Без conversion DD.MM.YYYY → DatetimeFieldOverflow
|
||||||
|
crash в upsert_*. Также валидируем что результат — actually-parseable date чтобы
|
||||||
|
invalid input не уехал в SQL.
|
||||||
|
"""
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
s = value.strip()
|
||||||
|
candidate: str | None = None
|
||||||
|
# ISO format already — pass through (PG accept YYYY-MM-DD)
|
||||||
|
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
|
||||||
|
candidate = s[:10]
|
||||||
|
# DD.MM.YYYY → YYYY-MM-DD
|
||||||
|
elif len(s) == 10 and s[2] == "." and s[5] == ".":
|
||||||
|
parts = s.split(".")
|
||||||
|
if len(parts) == 3 and all(p.isdigit() for p in parts):
|
||||||
|
candidate = f"{parts[2]}-{parts[1]}-{parts[0]}"
|
||||||
|
if candidate is None:
|
||||||
|
return None
|
||||||
|
# Validate: actually parseable as date (catches '2023-13-45', '32.13.2023', etc)
|
||||||
|
try:
|
||||||
|
_dt.date.fromisoformat(candidate)
|
||||||
|
return candidate
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -357,22 +357,65 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
|
||||||
logger.warning("enqueue_cadastre_harvest: job=%d имеет пустой список quarters", job_id)
|
logger.warning("enqueue_cadastre_harvest: job=%d имеет пустой список quarters", job_id)
|
||||||
return {"job_id": job_id, "enqueued": 0}
|
return {"job_id": job_id, "enqueued": 0}
|
||||||
|
|
||||||
|
# Fix H: per-quarter freshness skip — не переобрабатывать quarters с свежим
|
||||||
|
# cad_quarter_stats.fetched_at (последний успешный обход <FRESHNESS_HOURS).
|
||||||
|
# raw_targets.skip_fresh_hours=N управляет порогом; 0 → отключает skip.
|
||||||
|
# Хранится в существующем `raw_targets` JSONB (no schema change).
|
||||||
|
# Это сэкономит ~30k NSPD calls при повторных запусках того же scope.
|
||||||
|
skip_fresh_hours = int(raw_targets.get("skip_fresh_hours", 1) or 0)
|
||||||
|
skipped_fresh: set[str] = set()
|
||||||
|
if skip_fresh_hours > 0:
|
||||||
|
threshold = _dt.datetime.now(_dt.UTC) - _dt.timedelta(hours=skip_fresh_hours)
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT cad_number FROM cad_quarter_stats "
|
||||||
|
"WHERE cad_number = ANY(CAST(:qs AS text[])) "
|
||||||
|
"AND fetched_at >= :t"
|
||||||
|
),
|
||||||
|
{"qs": quarters, "t": threshold},
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
skipped_fresh = set(rows)
|
||||||
|
if skipped_fresh:
|
||||||
|
# Корректируем targets_total — skipped quarters не входят в прогресс
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE cadastre_jobs SET targets_total = :new_total, "
|
||||||
|
"targets_skipped = COALESCE(targets_skipped, 0) + :sk "
|
||||||
|
"WHERE job_id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"new_total": len(quarters) - len(skipped_fresh),
|
||||||
|
"sk": len(skipped_fresh),
|
||||||
|
"id": job_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Обновить статус на running
|
# Обновить статус на running
|
||||||
_update_job(db, job_id, status="running", started_at=_dt.datetime.now(_dt.UTC))
|
_update_job(db, job_id, status="running", started_at=_dt.datetime.now(_dt.UTC))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
enqueued = 0
|
||||||
for quarter in quarters:
|
for quarter in quarters:
|
||||||
|
if quarter in skipped_fresh:
|
||||||
|
continue
|
||||||
bulk_harvest_quarter_task.apply_async(
|
bulk_harvest_quarter_task.apply_async(
|
||||||
args=[quarter, job_id],
|
args=[quarter, job_id],
|
||||||
queue=CADASTRE_QUEUE,
|
queue=CADASTRE_QUEUE,
|
||||||
)
|
)
|
||||||
|
enqueued += 1
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"enqueue_cadastre_harvest: job=%d enqueued %d quarters",
|
"enqueue_cadastre_harvest: job=%d enqueued %d (skipped_fresh=%d, threshold=%dh)",
|
||||||
job_id,
|
job_id,
|
||||||
len(quarters),
|
enqueued,
|
||||||
|
len(skipped_fresh),
|
||||||
|
skip_fresh_hours,
|
||||||
)
|
)
|
||||||
return {"job_id": job_id, "enqueued": len(quarters)}
|
return {"job_id": job_id, "enqueued": enqueued, "skipped_fresh": len(skipped_fresh)}
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,44 @@ def test_safe_int_none_returns_none() -> None:
|
||||||
assert _safe_int(None) is None
|
assert _safe_int(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_nspd_date_handles_dd_mm_yyyy() -> None:
|
||||||
|
"""NSPD Сооружения/ОНС/ЕНК отдают даты в DD.MM.YYYY → конвертируем в ISO."""
|
||||||
|
from app.services.cadastre.bulk_harvest import _parse_nspd_date
|
||||||
|
|
||||||
|
assert _parse_nspd_date("13.03.2023") == "2023-03-13"
|
||||||
|
assert _parse_nspd_date("24.02.2022") == "2022-02-24"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_nspd_date_passes_through_iso() -> None:
|
||||||
|
"""ISO формат (YYYY-MM-DD) уже валидный — pass через."""
|
||||||
|
from app.services.cadastre.bulk_harvest import _parse_nspd_date
|
||||||
|
|
||||||
|
assert _parse_nspd_date("2023-03-13") == "2023-03-13"
|
||||||
|
assert _parse_nspd_date("2010-08-12 00:00:00") == "2010-08-12"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_nspd_date_none_and_invalid() -> None:
|
||||||
|
"""None / empty string / garbage → None."""
|
||||||
|
from app.services.cadastre.bulk_harvest import _parse_nspd_date
|
||||||
|
|
||||||
|
assert _parse_nspd_date(None) is None
|
||||||
|
assert _parse_nspd_date("") is None
|
||||||
|
assert _parse_nspd_date("nonsense") is None
|
||||||
|
assert _parse_nspd_date(123) is None # not str
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_nspd_date_validates_month_day_ranges() -> None:
|
||||||
|
"""Invalid month/day (13.03 / 32.13 / etc) → None — не пропускать в SQL."""
|
||||||
|
from app.services.cadastre.bulk_harvest import _parse_nspd_date
|
||||||
|
|
||||||
|
# Невалидный месяц (13) в ISO формате
|
||||||
|
assert _parse_nspd_date("2023-13-45") is None
|
||||||
|
# Невалидный день в DD.MM.YYYY
|
||||||
|
assert _parse_nspd_date("32.13.2023") is None
|
||||||
|
# Невалидный month
|
||||||
|
assert _parse_nspd_date("31.02.2023") is None # Feb 31
|
||||||
|
|
||||||
|
|
||||||
# ── Grid geometry ────────────────────────────────────────────────────────────
|
# ── Grid geometry ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1096,3 +1134,73 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
|
||||||
mock_heal.assert_called_once()
|
mock_heal.assert_called_once()
|
||||||
# Phase=done — harvest завершился штатно
|
# Phase=done — harvest завершился штатно
|
||||||
assert result.phase_state == {"phase": "done", "quarter": "66:41:0303161"}
|
assert result.phase_state == {"phase": "done", "quarter": "66:41:0303161"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fix I: heartbeat в grid-walk loop ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grid_walk_emits_heartbeat_callbacks() -> None:
|
||||||
|
"""_grid_walk_category должен вызывать update_progress каждые heartbeat_every cells.
|
||||||
|
|
||||||
|
225 cells (15×15) / 50 = 4 heartbeats minimum (idx=50, 100, 150, 200).
|
||||||
|
"""
|
||||||
|
from app.services.cadastre.bulk_harvest import _grid_walk_category
|
||||||
|
|
||||||
|
# Mock db: quarter_bbox_3857 returns valid bbox
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||||
|
"xmin": 6735845.0,
|
||||||
|
"ymin": 8329000.0,
|
||||||
|
"xmax": 6736595.0,
|
||||||
|
"ymax": 8329750.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock client: wms_feature_info returns empty list (no upserts to worry about)
|
||||||
|
client = AsyncMock()
|
||||||
|
client.wms_feature_info = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
progress_states: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
upserted, requests = await _grid_walk_category(
|
||||||
|
db=db,
|
||||||
|
client=client,
|
||||||
|
quarter="66:41:0303161",
|
||||||
|
layer_id=36368,
|
||||||
|
update_progress=lambda s: progress_states.append(s),
|
||||||
|
heartbeat_every=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 225 cells / 50 = 4 heartbeats at idx=50/100/150/200
|
||||||
|
assert len(progress_states) == 4
|
||||||
|
assert all("grid_cells_done" in s for s in progress_states)
|
||||||
|
assert progress_states[0]["grid_cells_done"] == 50
|
||||||
|
assert progress_states[-1]["grid_cells_done"] == 200
|
||||||
|
assert progress_states[0]["quarter"] == "66:41:0303161"
|
||||||
|
# 225 WMS calls (request counter)
|
||||||
|
assert requests == 225
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grid_walk_no_heartbeat_when_callback_none() -> None:
|
||||||
|
"""Если update_progress=None, не должны падать (callback optional)."""
|
||||||
|
from app.services.cadastre.bulk_harvest import _grid_walk_category
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||||
|
"xmin": 6735845.0,
|
||||||
|
"ymin": 8329000.0,
|
||||||
|
"xmax": 6736595.0,
|
||||||
|
"ymax": 8329750.0,
|
||||||
|
}
|
||||||
|
client = AsyncMock()
|
||||||
|
client.wms_feature_info = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
# Не передаём update_progress — должно не падать
|
||||||
|
upserted, requests = await _grid_walk_category(
|
||||||
|
db=db,
|
||||||
|
client=client,
|
||||||
|
quarter="66:41:0303161",
|
||||||
|
layer_id=36368,
|
||||||
|
)
|
||||||
|
assert requests == 225
|
||||||
|
|
|
||||||
132
backend/tests/workers/test_scrape_cadastre.py
Normal file
132
backend/tests/workers/test_scrape_cadastre.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""Тесты для backend/app/workers/tasks/scrape_cadastre.py.
|
||||||
|
|
||||||
|
Покрывает Fix H — per-quarter freshness skip в enqueue_cadastre_harvest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_job(quarters: list[str], raw_targets_extra: dict[str, Any] | None = None) -> dict:
|
||||||
|
"""Mock cadastre_jobs row."""
|
||||||
|
raw_targets = {"quarters": quarters}
|
||||||
|
if raw_targets_extra:
|
||||||
|
raw_targets.update(raw_targets_extra)
|
||||||
|
return {
|
||||||
|
"job_id": 99,
|
||||||
|
"status": "pending",
|
||||||
|
"raw_targets": raw_targets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_db(job: dict, fresh_quarters: list[str] | None = None) -> MagicMock:
|
||||||
|
"""Mock DB session.
|
||||||
|
|
||||||
|
1. db.execute("SELECT * FROM cadastre_jobs ...").mappings().first() → job
|
||||||
|
2. db.execute("SELECT cad_number FROM cad_quarter_stats ...").scalars().all() → fresh
|
||||||
|
3. db.execute(UPDATE) → no-op
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
fresh_quarters = fresh_quarters or []
|
||||||
|
|
||||||
|
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
|
||||||
|
sql = str(stmt).upper()
|
||||||
|
result = MagicMock()
|
||||||
|
if "FROM CADASTRE_JOBS" in sql and "WHERE JOB_ID" in sql and "SELECT *" in str(stmt):
|
||||||
|
result.mappings.return_value.first.return_value = job
|
||||||
|
elif "FROM CAD_QUARTER_STATS" in sql:
|
||||||
|
result.scalars.return_value.all.return_value = fresh_quarters
|
||||||
|
else:
|
||||||
|
# UPDATE / other queries
|
||||||
|
result.scalars.return_value.all.return_value = []
|
||||||
|
result.mappings.return_value.first.return_value = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
db.execute = execute_router
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_enqueue_skips_fresh_quarters_with_default_threshold() -> None:
|
||||||
|
"""Fix H: фреш кварталы (cad_quarter_stats.fetched_at < 1h) пропускаются."""
|
||||||
|
from app.workers.tasks import scrape_cadastre
|
||||||
|
|
||||||
|
quarters = ["66:41:0001001", "66:41:0001002", "66:41:0001003"]
|
||||||
|
fresh = ["66:41:0001001", "66:41:0001002"] # 2 свежих, 1 stale
|
||||||
|
|
||||||
|
job = _make_mock_job(quarters)
|
||||||
|
db = _make_mock_db(job, fresh_quarters=fresh)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(scrape_cadastre, "SessionLocal", return_value=db),
|
||||||
|
patch.object(scrape_cadastre.bulk_harvest_quarter_task, "apply_async") as mock_apply,
|
||||||
|
):
|
||||||
|
result = scrape_cadastre.enqueue_cadastre_harvest.run(job_id=99)
|
||||||
|
|
||||||
|
# Только 1 stale quarter должен попасть в apply_async
|
||||||
|
assert mock_apply.call_count == 1
|
||||||
|
assert mock_apply.call_args[1]["args"] == ["66:41:0001003", 99]
|
||||||
|
assert result["enqueued"] == 1
|
||||||
|
assert result["skipped_fresh"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_enqueue_no_skip_when_skip_fresh_hours_zero() -> None:
|
||||||
|
"""skip_fresh_hours=0 → отключает freshness check, все quarters enqueued."""
|
||||||
|
from app.workers.tasks import scrape_cadastre
|
||||||
|
|
||||||
|
quarters = ["66:41:0001001", "66:41:0001002"]
|
||||||
|
|
||||||
|
job = _make_mock_job(quarters, raw_targets_extra={"skip_fresh_hours": 0})
|
||||||
|
db = _make_mock_db(job, fresh_quarters=["66:41:0001001"]) # один свежий
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(scrape_cadastre, "SessionLocal", return_value=db),
|
||||||
|
patch.object(scrape_cadastre.bulk_harvest_quarter_task, "apply_async") as mock_apply,
|
||||||
|
):
|
||||||
|
result = scrape_cadastre.enqueue_cadastre_harvest.run(job_id=99)
|
||||||
|
|
||||||
|
# Все 2 quarters должны быть enqueued (skip отключен)
|
||||||
|
assert mock_apply.call_count == 2
|
||||||
|
assert result["enqueued"] == 2
|
||||||
|
assert result["skipped_fresh"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_enqueue_freshness_threshold_passed_in_sql() -> None:
|
||||||
|
"""skip_fresh_hours=2 → threshold = NOW() - 2h в SQL query."""
|
||||||
|
from app.workers.tasks import scrape_cadastre
|
||||||
|
|
||||||
|
quarters = ["66:41:0001001"]
|
||||||
|
captured_params: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
job = _make_mock_job(quarters, raw_targets_extra={"skip_fresh_hours": 2})
|
||||||
|
|
||||||
|
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
|
||||||
|
sql = str(stmt).upper()
|
||||||
|
result = MagicMock()
|
||||||
|
if "FROM CADASTRE_JOBS" in sql and "SELECT *" in str(stmt):
|
||||||
|
result.mappings.return_value.first.return_value = job
|
||||||
|
elif "FROM CAD_QUARTER_STATS" in sql:
|
||||||
|
if params:
|
||||||
|
captured_params.append(params)
|
||||||
|
result.scalars.return_value.all.return_value = []
|
||||||
|
else:
|
||||||
|
result.scalars.return_value.all.return_value = []
|
||||||
|
return result
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute = execute_router
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(scrape_cadastre, "SessionLocal", return_value=db),
|
||||||
|
patch.object(scrape_cadastre.bulk_harvest_quarter_task, "apply_async"),
|
||||||
|
):
|
||||||
|
scrape_cadastre.enqueue_cadastre_harvest.run(job_id=99)
|
||||||
|
|
||||||
|
assert len(captured_params) == 1
|
||||||
|
threshold: _dt.datetime = captured_params[0]["t"]
|
||||||
|
now = _dt.datetime.now(_dt.UTC)
|
||||||
|
# Threshold должен быть приблизительно 2h назад (±5s tolerance)
|
||||||
|
delta = now - threshold
|
||||||
|
assert _dt.timedelta(hours=1, minutes=59) < delta < _dt.timedelta(hours=2, minutes=1)
|
||||||
Loading…
Add table
Reference in a new issue