diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 85422aa1..6dabac98 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -32,6 +32,15 @@ _DUMP_MAX_AGE_DAYS = 180 # Радиус поиска инженерных сооружений (метры) — договорённость #44 I3. _ENGINEERING_RADIUS_M = 200 +# Issue #234: typical NSPD harvest duration. Frontend использует для countdown + +# auto-stop polling после ETA*1.5. Замерено по логам harvest_quarter task. +_HARVEST_ETA_SECONDS = 60 + +# Issue #234: SETNX lock TTL (секунды). Защищает от burst N concurrent analyze +# на один свежетриггеренный квартал — N одинаковых harvest task в очередь. +# TTL > _HARVEST_ETA_SECONDS чтобы lock жил пока task реально работает. +_HARVEST_LOCK_TTL_SECONDS = 120 + # Sentinel для isinstance-проверок и read-only fallback в parcels.py try/except. # НИКОГДА не мутировать — использовать make_empty_result() для новых dict. EMPTY_DUMP_RESULT: dict[str, Any] = { @@ -47,6 +56,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = { "stale": False, "harvest_triggered": False, "total_features": None, + "harvest_eta_seconds": None, }, } @@ -57,10 +67,15 @@ def make_empty_result( stale: bool = False, harvest_triggered: bool = False, total_features: int | None = None, + harvest_eta_seconds: int | None = None, ) -> dict[str, Any]: """Создаёт свежую копию empty-dump result с возможностью переопределить поля. Вызывать вместо EMPTY_DUMP_RESULT напрямую — чтобы не мутировать singleton. + + `harvest_eta_seconds` (issue #234): передавать когда harvest_triggered=True, + чтобы фронт показал countdown вместо бесконечного спиннера и остановил + re-poll после ETA*1.5. """ return { "nspd_zoning": None, @@ -75,6 +90,7 @@ def make_empty_result( "stale": stale, "harvest_triggered": harvest_triggered, "total_features": total_features, + "harvest_eta_seconds": harvest_eta_seconds, }, } @@ -151,7 +167,10 @@ def get_quarter_dump_data( cad_zouit_overlaps: list[dict[str, Any]] = ( _get_cad_zouit_overlaps(db, parcel_wkt) if parcel_wkt is not None else [] ) - result = make_empty_result(harvest_triggered=harvest_triggered) + result = make_empty_result( + harvest_triggered=harvest_triggered, + harvest_eta_seconds=_HARVEST_ETA_SECONDS if harvest_triggered else None, + ) result["nspd_zouit_overlaps"] = cad_zouit_overlaps return result @@ -180,6 +199,7 @@ def get_quarter_dump_data( stale=is_stale, harvest_triggered=harvest_triggered, total_features=total_features, + harvest_eta_seconds=_HARVEST_ETA_SECONDS if harvest_triggered else None, ) # Свежий дамп без ошибок — извлекаем spatial данные @@ -189,6 +209,7 @@ def get_quarter_dump_data( "stale": False, "harvest_triggered": False, "total_features": total_features, + "harvest_eta_seconds": None, } if parcel_wkt is None: @@ -1163,17 +1184,57 @@ def _get_zouit_engineering_overlaps( # ── Harvest trigger ─────────────────────────────────────────────────────────── +def _acquire_harvest_lock(quarter: str) -> bool: + """Redis SETNX lock на quarter с TTL=_HARVEST_LOCK_TTL_SECONDS. + + Issue #234: защита от burst N concurrent analyze_parcel запросов — без lock + каждый из них вызывал бы harvest_quarter.apply_async() и слал бы N + идентичных задач в Celery, тратя WAF traffic впустую (UPSERT идемпотентен, + но трафик не вернёшь). + + Returns: + True — lock acquired (caller должен enqueue task) + False — lock уже взят другим запросом ИЛИ redis недоступен (graceful + degradation: пропускаем дедуп, возвращаем False чтобы caller тоже не + триггерил — иначе одиночный redis-fail вернул бы поведение до фикса). + """ + try: + import redis + + from app.core.config import settings + + client = redis.Redis.from_url(settings.redis_url) + # SET key value NX EX — атомарно: «установи если не существует, TTL N сек». + acquired = client.set( + f"nspd_harvest_lock:{quarter}", + "1", + nx=True, + ex=_HARVEST_LOCK_TTL_SECONDS, + ) + return bool(acquired) + except Exception as e: + # Redis недоступен — лучше не запускать дубль, чем нагрузить WAF. + logger.warning("redis SETNX lock failed for quarter=%s: %s", quarter, e) + return False + + def _trigger_harvest(quarter: str) -> bool: - """Fire-and-forget harvest_quarter.apply_async(). Возвращает True если enqueue OK. + """Fire-and-forget harvest_quarter.apply_async() с Redis SETNX dedupe. Ленивый импорт чтобы избежать circular import (tasks → services → tasks). - Known limitation: burst из N concurrent analyze_parcel запросов на один - и тот же ещё не закешированный квартал может поставить N одинаковых задач - в очередь (нет дедупликации). UPSERT в harvest_quarter идемпотентен, поэтому - данные не портятся, но WAF traffic тратится впустую. - TODO: добавить Redis SETNX lock с TTL перед apply_async — отдельная issue/PR. + Issue #234: burst N concurrent analyze дедуплицируется через + `_acquire_harvest_lock` — только первый запрос ставит task, остальные + получают `False` (т.е. harvest_triggered=False, harvest_eta_seconds=None). + FE при таком ответе показывает graceful no-data UI с ПКК fallback link + (не infinite spinner). Trade-off: Request B не видит countdown, но и не + сбивает работу Request A. Это намеренный design choice — см. vault + fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md. """ + if not _acquire_harvest_lock(quarter): + logger.info("quarter=%s harvest skipped (lock held by another request)", quarter) + return False + try: from app.workers.tasks.nspd_sync import harvest_quarter diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index b11bd352..26165624 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -389,6 +389,70 @@ def test_make_empty_result_has_tier4_keys() -> None: assert result["nspd_red_lines"] == [] +# ── Issue #234: harvest_eta_seconds + SETNX dedupe ──────────────────────────── + + +def test_empty_dump_result_has_harvest_eta_seconds_key() -> None: + """EMPTY_DUMP_RESULT.nspd_dump содержит harvest_eta_seconds (issue #234).""" + assert "harvest_eta_seconds" in EMPTY_DUMP_RESULT["nspd_dump"] + assert EMPTY_DUMP_RESULT["nspd_dump"]["harvest_eta_seconds"] is None + + +def test_make_empty_result_passes_harvest_eta_seconds() -> None: + """make_empty_result(harvest_eta_seconds=60) → пробрасывает в nspd_dump.""" + result = make_empty_result(harvest_triggered=True, harvest_eta_seconds=60) + assert result["nspd_dump"]["harvest_eta_seconds"] == 60 + assert result["nspd_dump"]["harvest_triggered"] is True + + +def test_make_empty_result_default_eta_is_none() -> None: + """Если harvest_eta_seconds не передан — поле None (для available=False случая).""" + result = make_empty_result() + assert result["nspd_dump"]["harvest_eta_seconds"] is None + + +def test_acquire_harvest_lock_succeeds_first_call(monkeypatch: pytest.MonkeyPatch) -> None: + """SETNX lock: первый вызов получает lock (True), второй — нет (False). + + Issue #234: burst N concurrent analyze на один свежетриггеренный квартал + дедуплицируется через Redis SETNX. Мокаем redis.Redis.from_url. + """ + from app.services.site_finder import quarter_dump_lookup as qdl + + # Mock redis: первый client.set возвращает True, второй — None (key existed). + calls = {"n": 0} + + class _MockRedis: + def set(self, key: str, value: str, *, nx: bool, ex: int) -> bool | None: + calls["n"] += 1 + return True if calls["n"] == 1 else None + + monkeypatch.setattr( + "redis.Redis.from_url", + lambda _url: _MockRedis(), + ) + + assert qdl._acquire_harvest_lock("66:41:0204016") is True + assert qdl._acquire_harvest_lock("66:41:0204016") is False + assert calls["n"] == 2 + + +def test_acquire_harvest_lock_redis_unavailable_returns_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Redis недоступен → _acquire_harvest_lock возвращает False (graceful). + + Лучше не запустить дубль, чем нагрузить WAF при transient redis-fail. + """ + from app.services.site_finder import quarter_dump_lookup as qdl + + def _boom(_url: str) -> None: + raise ConnectionError("redis down") + + monkeypatch.setattr("redis.Redis.from_url", _boom) + assert qdl._acquire_harvest_lock("66:41:0204016") is False + + # ── derive_quarter_cad (smoke tests) ────────────────────────────────────────── diff --git a/frontend/src/components/site-finder/NspdFreshnessBadge.tsx b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx index c1033b74..5ed94a5a 100644 --- a/frontend/src/components/site-finder/NspdFreshnessBadge.tsx +++ b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx @@ -1,5 +1,7 @@ "use client"; +import { useEffect, useState } from "react"; + import type { NspdDumpMeta } from "@/types/nspd"; interface Props { @@ -7,6 +9,23 @@ interface Props { } export function NspdFreshnessBadge({ dump }: Props) { + // Issue #234: countdown пока harvest идёт. Сбрасывается на ETA при каждом + // новом triggered=true response (или ETA значении). + const eta = dump?.harvest_eta_seconds ?? null; + const [remaining, setRemaining] = useState(eta); + + useEffect(() => { + setRemaining(eta); + }, [eta]); + + useEffect(() => { + if (remaining === null || remaining <= 0) return; + const id = setInterval(() => { + setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1)); + }, 1000); + return () => clearInterval(id); + }, [remaining]); + if (!dump) { return ( 0 + ? `НСПД: загрузка ~${remaining}с` + : "НСПД: загрузка…"; return ( - НСПД: загрузка… + {countdownLabel} ); } diff --git a/frontend/src/components/site-finder/NspdZoningBlock.tsx b/frontend/src/components/site-finder/NspdZoningBlock.tsx index f9c90421..933e7da0 100644 --- a/frontend/src/components/site-finder/NspdZoningBlock.tsx +++ b/frontend/src/components/site-finder/NspdZoningBlock.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { NspdDumpMeta, NspdZoning } from "@/types/nspd"; @@ -14,9 +14,68 @@ export function NspdZoningBlock({ data, dump, cadNum }: Props) { const [expanded, setExpanded] = useState(false); const isHarvesting = dump?.harvest_triggered && !dump?.available; + const eta = dump?.harvest_eta_seconds ?? null; + + // Issue #234: countdown пока harvest идёт. + const [remaining, setRemaining] = useState(eta); + useEffect(() => { + setRemaining(eta); + }, [eta]); + useEffect(() => { + if (!isHarvesting) return; + if (remaining === null || remaining <= 0) return; + const id = setInterval(() => { + setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1)); + }, 1000); + return () => clearInterval(id); + }, [isHarvesting, remaining]); + + // Issue #234: после ETA*1.5 показываем «не дождались», fallthrough к no-data UI. + // remaining === 0 значит таймер успел истечь (eta был известен). + const harvestTimedOut = + isHarvesting && !data && remaining !== null && remaining <= 0; + if (harvestTimedOut) { + return ( +
+
+ Данные НСПД загружаются дольше обычного. Попробуйте обновить страницу + через минуту. +
+ + Открыть в ПКК + +
+ ); + } // Loading skeleton when harvest is in progress if (isHarvesting && !data) { + const label = + remaining !== null && remaining > 0 + ? `Загружаем данные НСПД, осталось ~${remaining} с…` + : "Загружаем данные НСПД…"; return (
- Загружаем данные НСПД, 15–30 с… + {label}