fix(#234): NSPD harvest ETA badge + SETNX dedupe (Sub-PR C) #250
5 changed files with 226 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
return (
|
||||
<span
|
||||
|
|
@ -27,6 +46,10 @@ export function NspdFreshnessBadge({ dump }: Props) {
|
|||
}
|
||||
|
||||
if (dump.harvest_triggered && !dump.available) {
|
||||
const countdownLabel =
|
||||
remaining !== null && remaining > 0
|
||||
? `НСПД: загрузка ~${remaining}с`
|
||||
: "НСПД: загрузка…";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
|
|
@ -38,9 +61,13 @@ export function NspdFreshnessBadge({ dump }: Props) {
|
|||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
title="Загрузка данных НСПД запущена"
|
||||
title={
|
||||
eta !== null
|
||||
? `Загрузка данных НСПД, осталось ~${remaining ?? 0} сек`
|
||||
: "Загрузка данных НСПД запущена"
|
||||
}
|
||||
>
|
||||
НСПД: загрузка…
|
||||
{countdownLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
if (isHarvesting && !data) {
|
||||
const label =
|
||||
remaining !== null && remaining > 0
|
||||
? `Загружаем данные НСПД, осталось ~${remaining} с…`
|
||||
: "Загружаем данные НСПД…";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -45,7 +104,7 @@ export function NspdZoningBlock({ data, dump, cadNum }: Props) {
|
|||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: "#1d4ed8", fontWeight: 500 }}>
|
||||
Загружаем данные НСПД, 15–30 с…
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ export interface NspdDumpMeta {
|
|||
stale: boolean;
|
||||
harvest_triggered: boolean;
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue