Merge pull request 'fix(#234): NSPD harvest ETA badge + SETNX dedupe (Sub-PR C)' (#250) from fix/234-nspd-harvest-eta-badge into main
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m42s
Deploy / build-worker (push) Successful in 2m44s
Deploy / build-frontend (push) Successful in 3m4s
Deploy / deploy (push) Successful in 55s

This commit is contained in:
lekss361 2026-05-17 06:35:40 +00:00
commit 9dd72ece0c
5 changed files with 226 additions and 11 deletions

View file

@ -32,6 +32,15 @@ _DUMP_MAX_AGE_DAYS = 180
# Радиус поиска инженерных сооружений (метры) — договорённость #44 I3. # Радиус поиска инженерных сооружений (метры) — договорённость #44 I3.
_ENGINEERING_RADIUS_M = 200 _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. # Sentinel для isinstance-проверок и read-only fallback в parcels.py try/except.
# НИКОГДА не мутировать — использовать make_empty_result() для новых dict. # НИКОГДА не мутировать — использовать make_empty_result() для новых dict.
EMPTY_DUMP_RESULT: dict[str, Any] = { EMPTY_DUMP_RESULT: dict[str, Any] = {
@ -47,6 +56,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = {
"stale": False, "stale": False,
"harvest_triggered": False, "harvest_triggered": False,
"total_features": None, "total_features": None,
"harvest_eta_seconds": None,
}, },
} }
@ -57,10 +67,15 @@ def make_empty_result(
stale: bool = False, stale: bool = False,
harvest_triggered: bool = False, harvest_triggered: bool = False,
total_features: int | None = None, total_features: int | None = None,
harvest_eta_seconds: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Создаёт свежую копию empty-dump result с возможностью переопределить поля. """Создаёт свежую копию empty-dump result с возможностью переопределить поля.
Вызывать вместо EMPTY_DUMP_RESULT напрямую чтобы не мутировать singleton. Вызывать вместо EMPTY_DUMP_RESULT напрямую чтобы не мутировать singleton.
`harvest_eta_seconds` (issue #234): передавать когда harvest_triggered=True,
чтобы фронт показал countdown вместо бесконечного спиннера и остановил
re-poll после ETA*1.5.
""" """
return { return {
"nspd_zoning": None, "nspd_zoning": None,
@ -75,6 +90,7 @@ def make_empty_result(
"stale": stale, "stale": stale,
"harvest_triggered": harvest_triggered, "harvest_triggered": harvest_triggered,
"total_features": total_features, "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]] = ( cad_zouit_overlaps: list[dict[str, Any]] = (
_get_cad_zouit_overlaps(db, parcel_wkt) if parcel_wkt is not None else [] _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 result["nspd_zouit_overlaps"] = cad_zouit_overlaps
return result return result
@ -180,6 +199,7 @@ def get_quarter_dump_data(
stale=is_stale, stale=is_stale,
harvest_triggered=harvest_triggered, harvest_triggered=harvest_triggered,
total_features=total_features, total_features=total_features,
harvest_eta_seconds=_HARVEST_ETA_SECONDS if harvest_triggered else None,
) )
# Свежий дамп без ошибок — извлекаем spatial данные # Свежий дамп без ошибок — извлекаем spatial данные
@ -189,6 +209,7 @@ def get_quarter_dump_data(
"stale": False, "stale": False,
"harvest_triggered": False, "harvest_triggered": False,
"total_features": total_features, "total_features": total_features,
"harvest_eta_seconds": None,
} }
if parcel_wkt is None: if parcel_wkt is None:
@ -1163,17 +1184,57 @@ def _get_zouit_engineering_overlaps(
# ── Harvest trigger ─────────────────────────────────────────────────────────── # ── 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: 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). Ленивый импорт чтобы избежать circular import (tasks services tasks).
Known limitation: burst из N concurrent analyze_parcel запросов на один Issue #234: burst N concurrent analyze дедуплицируется через
и тот же ещё не закешированный квартал может поставить N одинаковых задач `_acquire_harvest_lock` только первый запрос ставит task, остальные
в очередь (нет дедупликации). UPSERT в harvest_quarter идемпотентен, поэтому получают `False` (т.е. harvest_triggered=False, harvest_eta_seconds=None).
данные не портятся, но WAF traffic тратится впустую. FE при таком ответе показывает graceful no-data UI с ПКК fallback link
TODO: добавить Redis SETNX lock с TTL перед apply_async отдельная issue/PR. (не 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: try:
from app.workers.tasks.nspd_sync import harvest_quarter from app.workers.tasks.nspd_sync import harvest_quarter

View file

@ -389,6 +389,70 @@ def test_make_empty_result_has_tier4_keys() -> None:
assert result["nspd_red_lines"] == [] 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) ────────────────────────────────────────── # ── derive_quarter_cad (smoke tests) ──────────────────────────────────────────

View file

@ -1,5 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import type { NspdDumpMeta } from "@/types/nspd"; import type { NspdDumpMeta } from "@/types/nspd";
interface Props { interface Props {
@ -7,6 +9,23 @@ interface Props {
} }
export function NspdFreshnessBadge({ dump }: 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<number | null>(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) { if (!dump) {
return ( return (
<span <span
@ -27,6 +46,10 @@ export function NspdFreshnessBadge({ dump }: Props) {
} }
if (dump.harvest_triggered && !dump.available) { if (dump.harvest_triggered && !dump.available) {
const countdownLabel =
remaining !== null && remaining > 0
? `НСПД: загрузка ~${remaining}с`
: "НСПД: загрузка…";
return ( return (
<span <span
style={{ style={{
@ -38,9 +61,13 @@ export function NspdFreshnessBadge({ dump }: Props) {
fontSize: 11, fontSize: 11,
fontWeight: 500, fontWeight: 500,
}} }}
title="Загрузка данных НСПД запущена" title={
eta !== null
? `Загрузка данных НСПД, осталось ~${remaining ?? 0} сек`
: "Загрузка данных НСПД запущена"
}
> >
НСПД: загрузка {countdownLabel}
</span> </span>
); );
} }

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { NspdDumpMeta, NspdZoning } from "@/types/nspd"; import type { NspdDumpMeta, NspdZoning } from "@/types/nspd";
@ -14,9 +14,68 @@ export function NspdZoningBlock({ data, dump, cadNum }: Props) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const isHarvesting = dump?.harvest_triggered && !dump?.available; const isHarvesting = dump?.harvest_triggered && !dump?.available;
const eta = dump?.harvest_eta_seconds ?? null;
// Issue #234: countdown пока harvest идёт.
const [remaining, setRemaining] = useState<number | null>(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 (
<div
style={{
background: "#fef3c7",
border: "1px solid #fde68a",
borderRadius: 10,
padding: "14px 18px",
}}
>
<div style={{ fontSize: 13, color: "#92400e", marginBottom: 6 }}>
Данные НСПД загружаются дольше обычного. Попробуйте обновить страницу
через минуту.
</div>
<a
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-block",
marginTop: 6,
padding: "6px 14px",
background: "#92400e",
color: "#fff",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
}}
>
Открыть в ПКК
</a>
</div>
);
}
// Loading skeleton when harvest is in progress // Loading skeleton when harvest is in progress
if (isHarvesting && !data) { if (isHarvesting && !data) {
const label =
remaining !== null && remaining > 0
? `Загружаем данные НСПД, осталось ~${remaining} с…`
: "Загружаем данные НСПД…";
return ( return (
<div <div
style={{ style={{
@ -45,7 +104,7 @@ export function NspdZoningBlock({ data, dump, cadNum }: Props) {
}} }}
/> />
<span style={{ fontSize: 13, color: "#1d4ed8", fontWeight: 500 }}> <span style={{ fontSize: 13, color: "#1d4ed8", fontWeight: 500 }}>
Загружаем данные НСПД, 1530 с {label}
</span> </span>
</div> </div>
<div <div

View file

@ -30,6 +30,10 @@ export interface NspdDumpMeta {
stale: boolean; stale: boolean;
harvest_triggered: boolean; harvest_triggered: boolean;
total_features: number | null; total_features: number | null;
// Issue #234: typical harvest duration. Используется UI для countdown +
// auto-stop re-poll после ETA*1.5. null когда dump уже available или harvest
// не запущен (lock уже взят другим запросом).
harvest_eta_seconds?: number | null;
} }
// Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field // Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field