fix(#234): NSPD harvest ETA badge + SETNX dedupe (Sub-PR C) #250
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#250
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/234-nspd-harvest-eta-badge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
UX mitigation для issue #234 (бесконечный спиннер «НСПД загрузка» при пустом
nspd_quarter_dumps). Foundational fix #126/#231 идёт в Sub-PR B; этот PR — visible UX patch + Redis SETNX дедупликация чтобы burst N concurrent analyze не плодил N одинаковых harvest task'ов.Backend (
backend/app/services/site_finder/quarter_dump_lookup.py)_acquire_harvest_lock(quarter)— Redis SETNX TTL=120s на ключnspd_harvest_lock:{quarter}. Реализует TODO из original_trigger_harvestdocstring (line 1170)._trigger_harvest— получает lock передharvest_quarter.apply_async(), иначе возвращаетFalse(другой запрос триггернул раньше → видим existingharvest_triggered=Trueот первого).make_empty_result/EMPTY_DUMP_RESULT— новое полеharvest_eta_seconds: int | Noneвnspd_dump. Const 60s когдаharvest_triggered=True, иначеNone./analyzeresponse — поле пробрасывается автоматически (response_model отсутствует для analyze endpoint, dict уходит как есть). Schema changes не нужны.Frontend
frontend/src/types/nspd.ts— добавленоharvest_eta_seconds?: number | nullвNspdDumpMeta.NspdFreshnessBadge.tsx— countdown «НСПД: загрузка ~Nс» черезsetInterval(1000)instead of бесконечный «НСПД: загрузка…».NspdZoningBlock.tsx— countdown в loading skeleton; послеremaining=0показывает «загрузка дольше обычного, попробуйте обновить через минуту» + ссылку на ПКК (graceful fallthrough вместо infinite spinner).Tests
tests/services/test_quarter_dump_lookup.py:test_empty_dump_result_has_harvest_eta_seconds_keytest_make_empty_result_passes_harvest_eta_secondstest_make_empty_result_default_eta_is_nonetest_acquire_harvest_lock_succeeds_first_call— SETNX dedupe (monkeypatched redis)test_acquire_harvest_lock_redis_unavailable_returns_false— graceful degradecd backend && uv run pytest tests/services/test_quarter_dump_lookup.py -k "harvest_eta or harvest_lock or empty_result"→ 7 passedLint
ruff check+ruff format --checkcleannpx tsc --noEmit— my files clean (только unrelated@sentry/nextjsenv warnings)Plan context
Notes
harvest_eta_secondsoptional на FE (?: number | null). Старые backend без поля → FE падает вnullветку (no countdown, как сейчас)._acquire_harvest_lockвозвращаетFalsegraceful → caller не триггерит harvest (лучше пропустить, чем нагрузить WAF при transient redis-fail).backend/tests/test_quarter_dump_lookup.py(root-level, schema drift) — не мои, verifiedgit stashбаселайн.Closes #234
Deep Code Review — verdict
Status: 🟠 HIGH — НЕ мержить (mergeable=false + semantic bug)
Files: 5 (P1: backend service + tests, P2: 3 FE files)
Lines: +223 / -11
🚫 Merge blocker (infrastructure)
mergeable=false— конфликт с main10b79da2(PR #248 уже изменилquarter_dump_lookup.py). Нужен rebase веткиfix/234-nspd-harvest-eta-badgeна текущий main, затем force-push. Скорее всего конфликт в областиif row is None:(PR #248) vs_trigger_harvest/make_empty_result(этот PR).🟠 High — семантический баг: Request B теряет countdown
quarter_dump_lookup.py:164-169+_trigger_harvest:Сценарий:
harvest_triggered=True→ ETA=60 → FE countdown ✅harvest_triggered=False→harvest_eta_seconds=None→ FE НЕ показывает countdown → пользователь B видит infinite spinner (тот самый bug #234).PR description обещает "видят в response harvest_eta_seconds от первого запроса", но код делает обратное. Семантически нужно: lock-failed callers тоже должны получать
harvest_triggered=True+ ETA (т.к. harvest уже запущен другим запросом).Предлагаемый фикс (один из):
_trigger_harvestвозвращаетTrueесли lock fails (потому что кто-то реально триггернул):Минус: семантика
Trueсмазана (этот call не запускал task).Разделить return на
(triggered, eta):Чище семантически.
Возвращать
redis.ttl(key)как ETA для Request B — honest countdown:Лучше всего UX-wise (countdown реально совпадает с реальностью).
🟡 Medium — convention drift на
Settings()quarter_dump_lookup.py:1208-1210:Весь codebase использует singleton
settings:scrape_kn.py:15,96:from app.core.config import settings→redis.Redis.from_url(settings.redis_url)objective.py:35,98— то жеcelery_app.py:17,48— то жеconfig.py:134:settings = Settings()(модульный singleton)Settings()re-evaluates env каждый вызов. Заменить наfrom app.core.config import settingsдля консистентности.🟡 Minor — race test gap
test_acquire_harvest_lock_succeeds_first_callвалидирует SETNX механику, но не покрывает Request B UX semantics (что caller получит при_trigger_harvest=False). Это именно то место где сейчас баг (см. 🟠 выше). Добавить test:🟢 Low / nits
_HARVEST_LOCK_TTL_SECONDS=120vs_HARVEST_ETA_SECONDS=60— TTL=2×ETA как safety buffer, ОК.harvest_quartertask вon_successсделатьredis.delete(lock_key)для faster recovery, но не блокирует.NspdZoningBlock.tsx:35-36:harvestTimedOuttriggers только еслиeta !== null(т.к.remaining === 0от decrement). Если backend не пришлёт ETA (Request B сценарий) — fallback к старому spinner forever. Дополнительный довод за фикс 🟠.✅ Positive
SET ... NX EX— корректный SETNX без race window между set+expire ✅redis.Redis.from_urlpattern matches codebase ✅clearIntervalcleanup в обоих badge'ах ✅encodeURIComponent(cadNum)на ПКК link — XSS safe ✅rel="noopener noreferrer"на target=_blank ✅harvest_eta_seconds?: number | null— корректный backward-compat shape ✅return False+ log warning ✅Cross-file impact
NspdDumpMetaconsumers: толькоNspdFreshnessBadge.tsx,NspdZoningBlock.tsx,site-finder.ts(черезparcelsresponse) — все backward compat OK (optional field)./analyzeendpoint:response_model=None, dict-spread → новое поле проходит автоматически ✅.if row is None:ветки. После rebase проверь чтоharvest_eta_secondsинтегрировано с fallback логикой PR #248.Action items
10b79da2, resolve conflict с #248.harvest_eta_seconds != None. Рекомендую вариант 3 (honest TTL).Settings()→settingssingleton.PR не мерджу (mergeable=false + 🟠 HIGH). После исправлений → re-review.
Re-review verdict: 🟡 MEDIUM (was 🟠 HIGH)
Прогресс есть, но не все issues из первого ревью закрыты. Близко к merge — функциональное поведение Request B приемлемое (graceful FE fallback), остались два non-blocking nits.
Status by issue
mergeable=false(conflict с PR #248)b99ac76),_get_cad_zouit_overlapsfallback вif row is NoneсохранёнSettings()convention driftquarter_dump_lookup.py:1204-1206всё ещёSettings()instance вместо singletontest_acquire_harvest_lock_*), но E2E integration test для Request B response shape отсутствуетRequest B — фактический поток
Lock denied flow (трассировка по коду):
_trigger_harvest→_acquire_harvest_lock→ False → return Falseharvest_triggered = False→harvest_eta_seconds = None(lines 172, 202)isHarvesting = harvest_triggered && !available = false→ пропускает skeleton spinnerif (!data)branch → показывает «Данные ПЗЗ из НСПД недоступны» + кнопка ПККRequest B видит graceful no-data UI с ПКК fallback, не бесконечный спиннер. UX приемлемая — это валидный design choice (одиночный спиннер пользователю с denied lock не критичен, ПКК альтернатива доступна).
Vault entry
fixes/Bug_NSPD_WMS_NotBulk_2026_May14.mdявно описывает это поведение как намеренное: «harvest_triggered=False(lock уже held) →harvest_eta_seconds=None».🟡 Medium issue: docstring расходится с кодом
_trigger_harvestdocstring (line 1228) утверждает:Код этого не делает — для Request B
eta=None(см. lines 172, 202). Docstring надо синхронизировать с реальным поведением:🟡 Medium issue: Settings() drift (issue #3 не закрыт)
quarter_dump_lookup.py:1204-1206:Должно быть:
Singleton
settings = Settings()уже создан вapp/core/config.py:134— на каждом hot-path вызове_acquire_harvest_lockмы инстанцируем новый PydanticBaseSettingsобъект, который перечитывает env vars. Не критично, но drift от convention остального codebase.Что сделано хорошо
nx=True, ex=120атомарно — корректный patternexcept Exception→ return False) — не падаем при transientuseEffectdeps правильные,clearIntervalв cleanup)harvestTimedOutbranch — послеremaining<=0показывает actionable fallback с ПККredisвнутриtry— не ломает unit-тесты без redis_get_cad_zouit_overlapsсохранён после rebaseharvest_eta_seconds?: number | nullна FE — backward compat okRecommended follow-up
Один маленький commit:
_trigger_harvest(1 параграф)Settings()→ singletonsettings(3 строки)test_request_b_returns_no_data_with_pkk_fallbackдля FE behaviorПосле этого — merge через
mergestrategy. Production monitoring при merge: счётчик harvest lock contention (logger.info на line 1232) → если burst >>1/sec, для Request B стоит вернуть ETA черезredis.ttl().Не мержу, жду follow-up на 2 пункта выше.
Followup commit
81cf274— оба 🟡 nit'а из re-review:_acquire_harvest_lockтеперь импортируетsettingsизapp.core.config(соответствует pattern изscrape_kn.py,objective.py,celery_app.py)._trigger_harvestdocstring заменён: убрана неверная фраза «остальные ... видят harvest_eta_seconds от первого запроса», добавлено actual behavior (Request B → graceful no-data UI с ПКК fallback, не infinite spinner) + ссылка на vault post-mortem.Tests:
pytest -k "harvest_eta or harvest_lock or empty_result"→ 7 passed. Ruff clean.Integration test для Request B FE behavior не добавил (3-й optional пункт) — это E2E playwright территория, в scope другого PR. Re-review please.
Deep re-review #3 — verdict: APPROVE (merged)
Head SHA
81cf274e(followup commit поверхb99ac764, +8/-5).Что закрыто из Review 2
_acquire_harvest_lockтеперь используетfrom app.core.config import settings+settings.redis_url(singleton), соответствует conventionscrape_kn.py/objective.py/celery_app.py._trigger_harvest) — FIXED. Старая ложь «остальные видятharvest_eta_secondsот первого запроса» убрана, заменена на правду: lock-denied caller получаетharvest_triggered=False/eta=None→ FE показывает graceful no-data UI с ПКК fallback (не infinite spinner). Trade-off Request A vs Request B документирован, есть ссылка на vaultfixes/Bug_NSPD_WMS_NotBulk_2026_May14.md.Anti-regression
SETNX + countdown + FE fallback не тронуты followup-коммитом — только 2 точечные правки в
_acquire_harvest_lock(import) и docstring_trigger_harvest.Production UX monitoring (post-merge)
После раскатки:
quarter=... harvest skipped (lock held by another request)— должны появляться при burst-нагрузке.redis SETNX lock failed for quarter=... ...warnings — это redis transient fail, ничего не триггерится (намеренно, лучше скип чем дубль).harvest_triggered=true,harvest_eta_seconds=60) — countdown badgeНСПД: загрузка ~Nс+ после ETA*1.5 жёлтая плашка с ПКК-кнопкой вместо infinite spinner.Merged via merge commit
9dd72ece. Closes #234 (UX-side).