From 990c109756f8a049f8efa35c0dc63a6dbc0247cd Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 09:13:06 +0300 Subject: [PATCH 1/2] fix(#234): NSPD harvest ETA badge + SETNX dedupe (Sub-PR C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (quarter_dump_lookup.py): - _acquire_harvest_lock: Redis SETNX TTL=120s на quarter, защищает от burst N concurrent analyze, ставящих N одинаковых harvest task в очередь - _trigger_harvest: использует lock перед apply_async, возвращает False если lock уже взят (другой запрос триггернул раньше) - make_empty_result/EMPTY_DUMP_RESULT: новое поле harvest_eta_seconds в nspd_dump dict, типичный harvest_quarter = 60с - /analyze: пробрасывает поле через nspd_dump dict (нет typed schema — response_model=None для /analyze endpoint, dict уходит как есть) Frontend (NspdFreshnessBadge, NspdZoningBlock): - Countdown «НСПД: загрузка ~Nс» вместо бесконечного спиннера - После остановки countdown (remaining=0) NspdZoningBlock показывает «загрузка дольше обычного» + ссылку на ПКК вместо infinite skeleton Tests: 5 новых unit + 2 для empty_result schema (всего +7, pass) Closes #234 (UX-side; data-side resolves когда Sub-PR B + D merged). --- .../site_finder/quarter_dump_lookup.py | 72 +++++++++++++++++-- .../services/test_quarter_dump_lookup.py | 64 +++++++++++++++++ .../site-finder/NspdFreshnessBadge.tsx | 31 +++++++- .../site-finder/NspdZoningBlock.tsx | 63 +++++++++++++++- frontend/src/types/nspd.ts | 4 ++ 5 files changed, 223 insertions(+), 11 deletions(-) diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index fbf5c463..92a4a0b4 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, }, } @@ -148,7 +164,10 @@ def get_quarter_dump_data( if row is None: # Дампа нет — ставим harvest в очередь harvest_triggered = _trigger_harvest(quarter) - return make_empty_result(harvest_triggered=harvest_triggered) + return make_empty_result( + harvest_triggered=harvest_triggered, + harvest_eta_seconds=_HARVEST_ETA_SECONDS if harvest_triggered else None, + ) fetched_at: datetime = row[1] # Убедимся что timezone-aware для корректного сравнения @@ -175,6 +194,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 данные @@ -184,6 +204,7 @@ def get_quarter_dump_data( "stale": False, "harvest_triggered": False, "total_features": total_features, + "harvest_eta_seconds": None, } if parcel_wkt is None: @@ -1158,17 +1179,54 @@ 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-from N concurrent analyze дедуплицируется через + `_acquire_harvest_lock` — только первый запрос ставит task, остальные + получают `False` (т.е. harvest_triggered=False) но видят в response + `harvest_eta_seconds` от первого запроса и не re-poll сразу. """ + 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 3b23a011..345efb31 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -387,6 +387,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}
Date: Sun, 17 May 2026 09:31:43 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(#234):=20address=20re-review=20nits=20?= =?UTF-8?q?=E2=80=94=20Settings=20singleton=20+=20docstring=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. _acquire_harvest_lock: Settings() instance → singleton 'settings' import from app.core.config — соответствует convention (scrape_kn.py, objective.py, celery_app.py все используют singleton). 2. _trigger_harvest docstring: убрана incorrect фраза «остальные ... видят harvest_eta_seconds от первого запроса» (код возвращает eta=None для denied-lock callers). Заменена на actual behavior: graceful no-data UI с ПКК fallback на FE. PR #250 re-review followup (issues #3 + docstring sync). --- .../app/services/site_finder/quarter_dump_lookup.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 1b5dcd63..6dabac98 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -1201,9 +1201,9 @@ def _acquire_harvest_lock(quarter: str) -> bool: try: import redis - from app.core.config import Settings + from app.core.config import settings - client = redis.Redis.from_url(Settings().redis_url) + client = redis.Redis.from_url(settings.redis_url) # SET key value NX EX — атомарно: «установи если не существует, TTL N сек». acquired = client.set( f"nspd_harvest_lock:{quarter}", @@ -1223,10 +1223,13 @@ def _trigger_harvest(quarter: str) -> bool: Ленивый импорт чтобы избежать circular import (tasks → services → tasks). - Issue #234: burst-from N concurrent analyze дедуплицируется через + Issue #234: burst N concurrent analyze дедуплицируется через `_acquire_harvest_lock` — только первый запрос ставит task, остальные - получают `False` (т.е. harvest_triggered=False) но видят в response - `harvest_eta_seconds` от первого запроса и не re-poll сразу. + получают `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) -- 2.45.3