fix(#234): NSPD harvest ETA badge + SETNX dedupe (Sub-PR C)

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).
This commit is contained in:
lekss361 2026-05-17 09:13:06 +03:00
parent 6f565c19fa
commit 990c109756
5 changed files with 223 additions and 11 deletions

View file

@ -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

View file

@ -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) ──────────────────────────────────────────

View file

@ -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>
);
}

View file

@ -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 }}>
Загружаем данные НСПД, 1530 с
{label}
</span>
</div>
<div

View file

@ -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