feat(site-finder): integrate nspd_quarter_dumps cache в analyze_parcel (#94 Sprint 1.1 FINAL) #116
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#116
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/analyze-uses-quarter-dump"
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
Sprint 1.1 item #4 FINAL из #94 part 2 plan. Замыкает foundation:
analyze_parcelтеперь читает quarter dump cache и возвращает 4 новых поля — G1 ПЗЗ, G3 ЗОУИТ overlaps, I3 engineering proximity, freshness metadata.Sprint 1.1 progress
После merge — Sprint 1.1 закрыт, foundation готов. Frontend rendering этих 4 новых полей в UI — отдельный PR (next: #112 / #115 / #114).
Backend
backend/app/services/site_finder/quarter_dump_lookup.py(new)derive_quarter_cad(cad_num)get_quarter_dump_data(db, cad_num, parcel_wkt)EMPTY_DUMP_RESULTFlow:
nspd_quarter_dumpsдля derived quarterfetched_at_utc > NOW() - INTERVAL '180 days'ANDharvest_error IS NULLharvest_quarter.apply_async([quarter])fire-and-forget (lazy import против circular), returnEMPTY_DUMP_RESULTparcel_wkt=None→ metadata only (1 DB call)_get_zoning: point-in-polygon parcel centroid vsterritorial_zones, LIMIT 1_get_zouit_overlaps: всеzouit_%layers пересекающиеся с parcel_get_engineering_nearby:engineering_structuresв 200m, sorted by distanceВсе используют
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(feature->'geometry'), 3857), 4326)для inline 3857 raw NSPD в features_json → 4326 для intersection.backend/app/api/v1/parcels.py(modified)EMPTY_DUMP_RESULT, get_quarter_dump_datatry/except Exceptionсlogger.warning+EMPTY_DUMP_RESULTfallback. Защита от DB unavailability (table missing после deploy / timeout) — consistent с resilience pattern других optional fetches в endpointTests:
backend/tests/test_quarter_dump_lookup.py(13 new)Mock-based, no real DB. Coverage:
derive_quarter_cad: 5 edge cases (3seg/4seg/5seg/invalid/whitespace)get_quarter_dump: no_row, stale, harvest_error, no_geometry, fresh_zoning, fresh_zouit, no_match, includes_all_4_fields13/13 pass + 47 pre-existing still pass.
Code review (pre-push, code-reviewer agent)
Verdict: MINOR, 0 blocking. Applied 2 of 4 рекомендаций:
try/exceptwrap aroundget_quarter_dump_dataвanalyze_parcel+ DRYEMPTY_DUMP_RESULTmodule constant. Без этого еслиnspd_quarter_dumpsнедоступна → 500 instead of degraded response.nspd_zoning.fetched_at_utc(DRY — freshness живёт вnspd_dump.fetched_at_utc, не дублируется в zoning sub-object).harvest_quarterretry cooldown для harvest_error rows (только при high traffic + persistent NSPD errors). Acceptable defer — Celeryapply_asyncидемпотентен, лишний WAF traffic не страшен на текущем scale.raw_propsв response — tech debt, убрать вместе с frontend PR.Performance note
3 spatial queries per analyze adds ~10-50ms на typical ~100-feature quarter. analyze_parcel cumulative budget OK. Mitigation если quarters grow dense (>500 features): materialized per-layer sub-table — отдельная database-expert issue.
Acceptance (Sprint 1.1 #4 from #94 plan)
analyze_parcelreadsnspd_quarter_dumpsчерез helpernspd_zoning)nspd_zouit_overlaps)nspd_engineering_nearby)nspd_dump)Test plan
ruff check + format— cleanpytest tests/test_quarter_dump_lookup.py -v— 13/13 passedWhat's next
После merge #94 part 2 Sprint 1.1 closed. Очередь по user-выбранному порядку:
Sequential rule — следующий PR создаётся ТОЛЬКО после merge этого.
Part of #94. Closes Sprint 1.1 of part 2 plan.
⚠️ Auto-review: minor (non-blocking, M1 — real mutation risk)
Backend —
backend/app/services/site_finder/quarter_dump_lookup.py:M1 (real bug, fix first):
:131,136—EMPTY_DUMP_RESULTвозвращается напрямую как singleton.empty_result = EMPTY_DUMP_RESULT(ref, not copy) →return empty_resultна :136. Любая downstream-мутация испортит модульный sentinel для всех последующих запросов. Дополнительноdict(empty_result)на :159/:183 — это shallow copy, вложенныйnspd_dumpdict shared by reference. Fix: factory function_make_empty_result()илиdict(EMPTY_DUMP_RESULT) | {"nspd_dump": {...}}везде.M2 (architectural debt):
:236-394JSONB lateral expansion без GIN индекса. Все 3 spatial helper'а делаютjsonb_array_elements(d.features_json)+ST_Transform+ST_Intersects/ST_DWithinper element. Для квартала с 2000+ фичами — O(N) PostGIS-ops × 3 запроса per analyze_parcel. Митигейт прямо сейчас: pre-filter через денормализованные счётчики (territorial_zones_count > 0,zouit_count > 0,engineering_count > 0) перед отправкой тяжёлого запроса. Долгосрочно — отдельная PostGIS таблица для features. Создать GitHub issue для Sprint 1.2.M4:
:400-413нет дедупликации harvest triggers. Burst (10 concurrent requests один незакешированный квартал) → 10 harvest задач в очередь. Митигейт: Redis SETNX с TTL передapply_async. Документировать как known limitation.Tests —
backend/tests/test_quarter_dump_lookup.py::580-600_make_db_mock_with_spatialpositionally fragile — завязан на порядокdb.executeвызовов (0=dump, 1=zoning, 2=zouit, 3=engineering). Если реализация изменит порядок — тесты пройдут с неверными данными. Использоватьcall_args_listmatching по SQL или задокументировать contractual order.Positive:
:q,:wkt,:radius_m). Нет f-string SQL.ST_Transform(ST_SetSRID(..., 3857), 4326)всюду где features_json→4326. End-to-end CRS chain верна.except Exception → logger.warning + EMPTY_DUMP_RESULT.analyze_parcelне ломается.available=False. Не тихо отдаёт протухшие данные.derive_quarter_cad:isdigit()per segment validation._trigger_harvestкорректно решает circular import.print(), no hardcoded creds, norequests/psycopg2. Sync handler без async/await mixing.Fix and push — bot will re-review automatically.
Все 4 findings применены в `aef8308`:
M1 (mutation risk) ✅ — введён `_make_empty_result(**overrides)` factory. Каждый caller теперь получает независимый dict — никаких shared references на nested `nspd_dump`. `EMPTY_DUMP_RESULT` сохранён только для read-only fallback в parcels.py exception handler (там не мутируется).
M2 (O(N) spatial scan) ✅ — все 3 spatial helpers принимают `layer_counts` и делают early return при count=0. SELECT расширен `territorial_zones_count` / `zouit_count` / `engineering_count` (колонки уже в schema PR #110). Тяжёлый `jsonb_array_elements` пропускается полностью когда не нужен.
M4 (documentation) ✅ — `_trigger_harvest` docstring описывает burst/no-dedup limitation + TODO Redis SETNX для следующего PR.
M5 (test fragility) ✅ — `_make_db_mock_with_spatial` docstring describes positional-call contract.
+4 новых tests (17 total, all pass):
_make_empty_resultruff + format clean. Ждём re-review.
⚠️ Auto-review: minor (non-blocking) — fixup частично:
Fixed ✅:
quarter_dump_lookup.py:_make_empty_result()factory создаёт fresh nested dicts на каждый вызов. Все return sites внутриget_quarter_dump_dataиспользуют factory. Singleton safe внутри модуля.territorial_zones_count/zouit_count/engineering_count(правильные имена per schema PR#110). Positionalrow[4]/[5]/[6]matches SELECT order. Default-1 guards корректны.Not fixed / deferred ⚠️:
M1 residual:
parcels.py:22exception path —nspd_dump_data = EMPTY_DUMP_RESULT(singleton) вместо_make_empty_result(). Сейчас readonly access (["nspd_zoning"]etc.) — mutation не происходит, но нарушает документированный invariant модуля. Fix: импортировать_make_empty_resultвparcels.py:9и использовать в except block.M4 deferred:
_trigger_harvestимеет TODO comment, Redis SETNX не реализован. Reasonable trade-off (UPSERT idempotency делает data-safe), но создаёт burst-duplicate task'и в очереди. Document как known limitation в vault или создать follow-up issue для Sprint 1.2.M5 deferred:
_make_db_mock_with_spatialвсё ещё positionalcall_countdispatch. Fragility задокументирована в docstring но не устранена.test_get_quarter_dump_fresh_zouit_overlaps:716явно устанавливаетterritorial_zones_count=1искусственно чтобы сохранить slot alignment — это test smell, подтверждает что M5 fragility реальна. Fix at next test touch: match по SQL content черезcall_args_list.Fix and push — bot will re-review automatically. (Или подтвердите явно "approved" / "merge it" если хотите смерджить с deferred items как-есть.)
M1 residual применён в `7ca4d35`:
✅ `_make_empty_result` → `make_empty_result` (public API). Импортирован в `parcels.py` exception path вместо `EMPTY_DUMP_RESULT` singleton. Каждый request получает независимый dict.
M4 (Redis SETNX dedup) и M5 (test fragility positional order) — deferred per review как acceptable trade-off:
17/17 tests pass. ruff/format clean. Ждём re-review.
✅ Auto-review passed (LGTM) — все residual findings корректно resolved в
7ca4d35.Fixes verified:
parcels.py:26теперь вызываетmake_empty_result()в exception path (неEMPTY_DUMP_RESULTsingleton). Import :9-12 включает оба:get_quarter_dump_data+make_empty_result. Inline comment "Independent dict per request — never mutate module singleton" документирует invariant. Mutation-safety test :784 verifies freshnspd_dumpsub-dict +nspd_zouit_overlapslist per call._trigger_harvest :459записывает burst-duplicate limitation + TODO Redis SETNX для следующего PR. UPSERT idempotency safety net acknowledged. No data correctness risk._make_db_mock_with_spatial :635-643docstring explicitly explains positional contract, 7-element tuple schema, reorder instruction. Test self-aware, не silent.Positive:
:q/:wkt/:radius_m) — no injection.print().make_empty_result()создаёт fresh nested dict + sub-dict per call — verified test'ом.EMPTY_DUMP_RESULTkept как sentinel для isinstance check'ов и mutation-test assertions._trigger_harvestlazy-importsharvest_quarter— correct pattern для circular import avoidance.derive_quarter_cadcovers 3/4/5-segment, whitespace, non-digit, short inputs — edge cases handled.Cosmetic nit (не блокер): test functions
testmake_empty_result_*:784/:807 без underscore послеtest— pytest discovers их поtest-prefix, но inconsistent с остальным файлом (test_*стиль). Можно поправить при следующем касании.Merging.