feat(nspd): harvest_quarter Celery task + beat + admin endpoint (#94 pt.4 part A) #111
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#111
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/nspd-harvest-quarter-task"
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 #3 из #94 part 2. Замыкает harvest cycle:
QuarterDump+NSPDClient.search_by_quarter()nspd_quarter_dumpstable schemaanalyze_parcelreads dump cacheПосле этого PR data harvesting автоматизирован. После следующего —
analyze_parcelперестанет делать NSPD HTTP в request-цикле (читает изnspd_quarter_dumpscache).Backend changes
backend/app/workers/tasks/nspd_sync.py(new)Error handling:
NspdLiteWafError(403/429) → autoretry с exponential backoff. Maximum 3 retries.harvest_error=str(exc), counts=0. НЕ re-raise (избегаем silent task failure).SQL для поиска stale:
backend/app/workers/celery_app.pynspd_syncвincludenspd-harvest-stale-quarters: Mon 04:00 МСК (crontab(hour=4, minute=0, day_of_week="mon")), batch_size=50, max_age_days=90worker_readysanity checkSELECT 1 FROM nspd_quarter_dumps LIMIT 0(non-fatal critical log perBug_Worker_Ready_EarlyReturn_Fixed)backend/app/api/v1/admin_scrape.pyPattern
^\d+:\d+:\d+$rejects 4-сегментный cad (only quarters allowed для этого task).Critical contracts
ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857), 4326))на ingestST_MakeEnvelope(:xmin, :ymin, :xmax, :ymax, 3857){a,b,c}через string concatquarter_geom=NULL,bbox_3857=NULL,features_json='[]', counts=0, no errorharvest_error=str(exc), counts=0 — re-tried в следующий beatTests:
backend/tests/test_nspd_sync.py(14 tests)Mock-based, no real NSPD/DB:
test_harvest_quarter_happy_path— search_by_quarter mock → verify UPSERT SQLtest_harvest_quarter_empty_quarter— NSPD пуст → row with null geometrytest_harvest_quarter_waf_retry—NspdLiteWafError→ re-raise (autoretry)test_harvest_quarter_generic_error_writes_row— generic exc → upsert error row, no re-raisetest_harvest_stale_quarters_fanout— 50 stale → 50 apply_async callstest_harvest_stale_quarters_batch_limit—batch_size=10→ 10 calls maximumtest_features_json_zouit_groups—include_zouit=True→ all 5 zouit keys presenttest_features_json_risks_groups—include_risks=True→ all 11 risk keystest_features_json_layer_assignment— каждый feature имеет правильный layer nametest_upsert_with_geom_uses_st_multi— verify SQL usesST_Multi(ST_Transform(...))test_upsert_without_geom_uses_null— empty quarter → CASE WHEN NULL THEN NULLuv run pytest tests/test_nspd_sync.py -v→ 14/14 passed.Code review (pre-push, code-reviewer agent)
Verdict: APPROVE, 0 blocking, 5 minor (all acceptable defer):
import datetimein error else-branch (style, technical debt)layers_fetchedarray literal via string concat — works for current layer names; risky only if future layer name contains,or{harvest_stale_quartersre-tries error rows infinitely — intended behavior (manual cleanup or retry-count cap later)call_args.kwargs["args"]fragile if call site changesrefresh-ekb-districts-mediansonce every ~2 years (negligible)Acceptance (per #94 plan)
harvest_quartertask implemented с WAF retryharvest_stale_quartersbeat fanoutTest plan
ruff check + format— cleanpytest tests/test_nspd_sync.py -v— 14/14 passednspd-harvest-stale-quartersв schedule (logs)Sequential rule — следующий PR (analyze_parcel integration) только после merge этого.
Part of #94.
⚠️ Auto-review: minor (non-blocking, но M1 — runtime bug)
Backend —
backend/app/workers/tasks/nspd_sync.py::477harvest_stale_quartersзапрашиваетregion_codeизcad_quarters_geom, но этой колонки нет в схеме. Prod schema:(cad_number, geom, raw_props, fetched_at, source). При первом beat tick'е получимcolumn "region_code" does not exist. Fix: убратьWHERE region_code = :rcиз outer SELECT поcad_quarters_geom(inner subquery поnspd_quarter_dumpsкорректно используетregion_code)._upsert_dump :273-335не делает rollback на exception.finally: db.close()безexcept: rollback()→PendingRollbackErrorпри PostGIS отбраковке geometry или JSON cast failure.harvest_stale_quarters :497-500уже имеет explicit rollback — повторить паттерн в_upsert_dump.:299layers_fetchedarray literal через string join —"{" + ",".join(...) + "}"обходит psycopg v3 native array binding. SQL уже имеетCAST(:layers_fetched AS text[])— заменить на"layers_fetched": list(dump.layers_fetched).:307import datetime as _dtвнутри else branch — top-of-file import convention. Поднять наверх.backend/app/workers/celery_app.py::94-111worker_ready sanity-check — inner try/finally безrollback()перед_db.close(). Low priority (SELECT 1), но консистентно с проектом стоит добавить.backend/tests/test_nspd_sync.py::819-844mock__enter__/__exit__наSessionLocal()— ноharvest_stale_quartersне использует context manager. Эти моки no-op. Test работает потому чтоmock_db.execute.return_value.all.return_valueзадан напрямую. Cleanup для clarity.Positive:
_check_token(x_admin_token) :34— auth correct per project pattern.HarvestQuarterRequestPydantic regex^\d+:\d+:\d+$+ range guards — input validated before enqueue.autoretry_for=(NspdLiteWafError,),retry_backoff=True,retry_jitter=True,max_retries=3— корректный Celery pattern для WAF.ON CONFLICT (quarter_cad) DO UPDATE,region_codeисключён из UPDATE (immutable).worker_readyНЕ повторяетBug_Worker_Ready_EarlyReturn_Fixed— nested try/finally безreturn.print(),requests,psycopg2.logger.*всюду.M6 deferred import в admin_scrape.py — by design per file convention, not an issue.
Fix and push — bot will re-review automatically.
Все 6 findings применены в `aca4b44`:
M1 (runtime bug) ✅ — `harvest_stale_quarters` убрал `WHERE region_code` из outer SELECT по `cad_quarters_geom` (колонки нет в prod schema). Inner subquery по `nspd_quarter_dumps` сохраняет фильтр.
M2 (correctness) ✅ — `_upsert_dump` теперь `except Exception: db.rollback(); raise` перед finally close. PendingRollbackError prevented.
M3 (low) ✅ — `worker_ready` breadcrumb INSERT — same rollback pattern для consistency.
M4 (style) ✅ — `layers_fetched` — native Python `list` вместо string concat `'{' + ','.join(...) + '}'`. psycopg v3 сам сериализует.
M5 (style) ✅ — `import datetime as _dt` → top-level imports.
M7 (test cleanup) ✅ — dead `enter`/`exit` mock setup удалён.
`ruff` + `format` + 14/14 tests pass. Ждём re-review.
✅ Auto-review passed (LGTM) — все 5 actionable findings корректно применены.
Fixes verified:
SELECT cad_number FROM cad_quarters_geom :492-503— безWHERE region_code.region_code = :rcостался только в inner subquery поnspd_quarter_dumpsгде колонка существует. No more runtime crash._upsert_dump :343-347—except Exception: db.rollback(); raiseпередfinally: db.close(). Pattern correct.celery_app.py :90-92—except Exception: _db.rollback(); raiseв_resume_zombie_runsinner try/finally. Outer try/except :95 catches+logs.layers_fetchedтеперьlist(dump.layers_fetched)(lines 310 + 414).CAST(:layers_fetched AS text[])получает Python list, psycopg v3 нативно binds — no string-join bypass.import datetime as _dtна top of file (line 148). Error-only path :335 использует_dt.datetime.now(_dt.UTC).Bonus cleanup:
__enter__/__exit__mock) удалён из теста.harvest_stale_quarters:508-511 — explicittry: db.rollback() except: passперед return error dict — корректный pattern.No regressions. Admin endpoint
_check_token:34 intact. Deferred import :35 (M6) by-design per file convention.Merging.