fix(#244): harvest_quarter — CAST CASE WHEN params (psycopg3 AmbiguousParameter) #246

Merged
lekss361 merged 1 commit from fix/244-harvest-quarter-cast-ambiguous-parameter into main 2026-05-17 05:53:08 +00:00
Owner

Symptom

harvest_quarter падает на INSERT в nspd_quarter_dumps:

psycopg.errors.AmbiguousParameter: could not determine data type of parameter $2
LINE 11:         CASE WHEN $2 IS NULL THEN NULL

15+ events в GlitchTip. nspd_quarter_dumps=0 строк глобально. Блокировал #115, #231, #234, #235, #243.

Root cause

_UPSERT_SQL использовал голые bind-параметры внутри CASE WHEN ... IS NULL. psycopg3 не может вывести тип $N когда один branch — NULL литерал, другой — PostGIS geometry / float.

Fix

backend/app/workers/tasks/nspd_sync.py:

  • CAST(:geom_json AS text) для ST_GeomFromGeoJSON
  • CAST(:bbox_xmin AS double precision) (ymin/xmax/ymax аналогично) для ST_MakeEnvelope

Side fix

Pre-existing тесты не обновлялись когда QuarterDump получил обязательное поле opportunity. Добавлено opportunity={} в 3 inline-конструктора в test_nspd_sync.py.

Test plan

  • 16/16 тестов tests/test_nspd_sync.py проходят
  • 2 regression-теста: test_upsert_dump_null_geom_executes_without_error + test_upsert_dump_with_geom_executes_without_error
  • ruff + pre-commit — passed
  • After deploy smoke:
    curl -X POST https://gendsgn.ru/api/v1/admin/scrape/nspd/harvest-quarter \
      -H "X-Admin-Token: $TOKEN" \
      -d '{"quarter_cad":"66:41:0204016","region_code":66}'
    # SELECT count(*) FROM nspd_quarter_dumps;  -- expect > 0
    
## Symptom `harvest_quarter` падает на INSERT в `nspd_quarter_dumps`: ``` psycopg.errors.AmbiguousParameter: could not determine data type of parameter $2 LINE 11: CASE WHEN $2 IS NULL THEN NULL ``` 15+ events в GlitchTip. `nspd_quarter_dumps=0` строк глобально. Блокировал #115, #231, #234, #235, #243. ## Root cause `_UPSERT_SQL` использовал голые bind-параметры внутри `CASE WHEN ... IS NULL`. psycopg3 не может вывести тип `$N` когда один branch — `NULL` литерал, другой — PostGIS geometry / float. ## Fix `backend/app/workers/tasks/nspd_sync.py`: - `CAST(:geom_json AS text)` для `ST_GeomFromGeoJSON` - `CAST(:bbox_xmin AS double precision)` (ymin/xmax/ymax аналогично) для `ST_MakeEnvelope` ## Side fix Pre-existing тесты не обновлялись когда `QuarterDump` получил обязательное поле `opportunity`. Добавлено `opportunity={}` в 3 inline-конструктора в `test_nspd_sync.py`. ## Test plan - [x] 16/16 тестов `tests/test_nspd_sync.py` проходят - [x] 2 regression-теста: `test_upsert_dump_null_geom_executes_without_error` + `test_upsert_dump_with_geom_executes_without_error` - [x] ruff + pre-commit — passed - [ ] After deploy smoke: ```bash curl -X POST https://gendsgn.ru/api/v1/admin/scrape/nspd/harvest-quarter \ -H "X-Admin-Token: $TOKEN" \ -d '{"quarter_cad":"66:41:0204016","region_code":66}' # SELECT count(*) FROM nspd_quarter_dumps; -- expect > 0 ```
lekss361 added 1 commit 2026-05-17 05:48:33 +00:00
psycopg3 could not determine the type of bare bind params inside CASE WHEN
... IS NULL expressions in _UPSERT_SQL. Wrap geom_json as CAST(:geom_json AS text)
and bbox coords as CAST(:bbox_xmin AS double precision) so Postgres has an explicit
type hint in both branches.

Also fixes pre-existing test breakage: QuarterDump gained required field
`opportunity` in a prior PR; added opportunity={} to all 3 inline QuarterDump
constructors in test_nspd_sync.py. Adds 2 regression tests for _upsert_dump
(null-geom path and real-geom path) to catch re-introduction.
Author
Owner

Deep review — APPROVE

Verdict: APPROVE, мержу.

Что проверено

  • _UPSERT_SQL: все 5 параметров (geom_json, bbox_xmin/ymin/xmax/ymax) обёрнуты CAST(... AS ...) в обоих branch + в IS NULL; SRID 3857 корректно остаётся literal. Схема nspd_quarter_dumps (88_*.sql) подтверждает nullable quarter_geom/bbox_3857.
  • :geom_json / :bbox_* использованы в IS NULL и в ST_* — двойной CAST правильный (psycopg3 выводит тип из любого branch).
  • grep ':[a-z_]+::[a-z]' по nspd_sync.py — чисто, никаких ::type не осталось.
  • ON CONFLICT (quarter_cad) DO UPDATE идемпотентен, region_code корректно исключён из UPDATE.
  • Side fix opportunity={} × 3 — QuarterDump.opportunity обязательное в dataclass; {} semantically чище None.
  • Regression тесты — mock-based, проверяют что SessionLocal.execute() вызывается. Real-driver AmbiguousParameter они не воспроизведут, но smoke-curl после deploy это покроет.

Blast radius (не в этом PR, отдельным fix)

Тот же anti-pattern в backend/app/services/scrapers/nspd_denorm.py:81 и :166:

CASE WHEN :geom_json IS NULL THEN NULL
     ELSE ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom_json), 3857), 4326)
END

Denorm вызывается из harvest_quarter (line 316) и упадёт на той же AmbiguousParameter, но молча — каждая строка обёрнута with db.begin_nested() + except → logger.warning. Подтверждено по БД: nspd_parcels=0, nspd_buildings=0 — denorm ничего не записал. После merge #246 квартал-дампы заработают, но parcels/buildings всё ещё будут пустыми. Нужен follow-up PR — wrap :geom_json в CAST(... AS text) и в _PARCEL_UPSERT_SQL, и в _BUILDING_UPSERT_SQL.

Vault

fixes/Bug_Nspd_Harvest_Quarter_Cast_May17.md уже создан — отлично.

Post-deploy smoke

# 1. trigger harvest
curl -X POST https://gendsgn.ru/api/v1/admin/scrape/nspd/harvest-quarter \
  -H "X-Admin-Token: $TOKEN" \
  -d '{"quarter_cad":"66:41:0204016","region_code":66}'

# 2. через ~30 sec — проверить запись
psql -c "SELECT quarter_cad, total_features, harvest_error, fetched_at_utc
         FROM nspd_quarter_dumps WHERE quarter_cad='66:41:0204016';"
# expect: total_features > 0, harvest_error IS NULL

# 3. denorm check (вероятно всё ещё 0 — см. blast radius)
psql -c "SELECT COUNT(*) FROM nspd_parcels WHERE quarter_cad='66:41:0204016';"

После smoke — если nspd_parcels=0, открывать follow-up issue на nspd_denorm.py CAST.

## Deep review — APPROVE **Verdict:** APPROVE, мержу. ### Что проверено - `_UPSERT_SQL`: все 5 параметров (`geom_json`, `bbox_xmin/ymin/xmax/ymax`) обёрнуты `CAST(... AS ...)` в обоих branch + в IS NULL; SRID `3857` корректно остаётся literal. Схема `nspd_quarter_dumps` (`88_*.sql`) подтверждает nullable `quarter_geom`/`bbox_3857`. - `:geom_json` / `:bbox_*` использованы в `IS NULL` и в `ST_*` — двойной CAST правильный (psycopg3 выводит тип из любого branch). - `grep ':[a-z_]+::[a-z]'` по `nspd_sync.py` — чисто, никаких `::type` не осталось. - `ON CONFLICT (quarter_cad) DO UPDATE` идемпотентен, `region_code` корректно исключён из UPDATE. - Side fix `opportunity={}` × 3 — `QuarterDump.opportunity` обязательное в dataclass; `{}` semantically чище `None`. - Regression тесты — mock-based, проверяют что `SessionLocal.execute()` вызывается. Real-driver AmbiguousParameter они не воспроизведут, но smoke-curl после deploy это покроет. ### Blast radius (не в этом PR, отдельным fix) Тот же anti-pattern в `backend/app/services/scrapers/nspd_denorm.py:81` и `:166`: ```sql CASE WHEN :geom_json IS NULL THEN NULL ELSE ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom_json), 3857), 4326) END ``` Denorm вызывается из `harvest_quarter` (line 316) и упадёт на той же AmbiguousParameter, но **молча** — каждая строка обёрнута `with db.begin_nested()` + `except → logger.warning`. Подтверждено по БД: `nspd_parcels=0`, `nspd_buildings=0` — denorm ничего не записал. После merge #246 квартал-дампы заработают, но parcels/buildings всё ещё будут пустыми. Нужен follow-up PR — wrap `:geom_json` в `CAST(... AS text)` и в `_PARCEL_UPSERT_SQL`, и в `_BUILDING_UPSERT_SQL`. ### Vault `fixes/Bug_Nspd_Harvest_Quarter_Cast_May17.md` уже создан — отлично. ### Post-deploy smoke ```bash # 1. trigger harvest curl -X POST https://gendsgn.ru/api/v1/admin/scrape/nspd/harvest-quarter \ -H "X-Admin-Token: $TOKEN" \ -d '{"quarter_cad":"66:41:0204016","region_code":66}' # 2. через ~30 sec — проверить запись psql -c "SELECT quarter_cad, total_features, harvest_error, fetched_at_utc FROM nspd_quarter_dumps WHERE quarter_cad='66:41:0204016';" # expect: total_features > 0, harvest_error IS NULL # 3. denorm check (вероятно всё ещё 0 — см. blast radius) psql -c "SELECT COUNT(*) FROM nspd_parcels WHERE quarter_cad='66:41:0204016';" ``` После smoke — если `nspd_parcels=0`, открывать follow-up issue на `nspd_denorm.py` CAST.
lekss361 merged commit 992477c8f8 into main 2026-05-17 05:53:08 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#246
No description provided.