gendesign/backend/app/workers/tasks/supply_layers_refresh.py
Light1YT ed3b302d57
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m22s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m4s
fix(supply-layers): thread dev_group_name into L3 upsert key (#970 CRITICAL)
REOPENED. L3 future-supply rows are computed per (district_name, dev_group_name)
but dev_group_name was never a key column — only embedded in method text. With
complex_id/obj_class NULL for L3, every dev_group of a district collapsed to one
upsert key → ~95.6% loss. Ground-truth (Академический, prod): should be 13,808
units / 15 dev_groups / 54 objects; only 1 row / 607 units survived.

Migration 128: ADD COLUMN supply_layers.dev_group_name TEXT + rebuild
uq_supply_layers_logical to (layer, district_name, complex_id, obj_class,
dev_group_name, source, snapshot_date) NULLS NOT DISTINCT (L1/L2 dev_group_name
NULL stays transparent → their dedup unchanged; L3 distinct groups no longer
collapse). Dry-run-verified vs prod catalog (applies clean, ROLLBACK clean).

Worker: SupplyLayerRow gains dev_group_name (L1/L2=None, L3=group); _UPSERT_SQL
adds it to INSERT/VALUES (CAST(:dev_group_name AS text)) + ON CONFLICT (key col,
not in DO UPDATE SET). Service+worker regression tests assert same-district/
different-dev_group → distinct keys (no collapse). 234 supply tests pass.

Deploy applies migration before container restart; collapsed data self-heals on
next supply_layers_refresh. Verification = prod re-measure post-deploy.

Refs #970
2026-06-04 10:06:30 +05:00

226 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Celery task: пересчёт 3-слойного склада предложения → ``supply_layers`` (м.125).
#950 (Site Finder v2 / GG-форсайт, EPIC 6 «3-layer склад предложения», ТЗ §9.3),
Step 5. Для каждого района Свердловской обл. вызывает детерминированный сервис
``supply_layers.compute_all_layers`` (L1 open / L2 hidden / L3 future) и UPSERT-ит
полученные ``SupplyLayerRow`` в таблицу ``supply_layers`` по логическому ключу
``uq_supply_layers_logical`` (layer, district_name, complex_id, obj_class,
dev_group_name, source, snapshot_date) — dev_group_name в ключе с м.128 (#970), иначе
dev_group'ы района схлопывались (~95.6% потерь L3).
Детерминированно, без LLM — сервис read-only считает агрегаты, этот worker только
пишет. ON CONFLICT делает повторные прогоны идемпотентными в рамках одного дня
(re-run того же ``snapshot_date`` обновляет мутабельные колонки + computed_at).
Контракт записи (КРИТИЧНО, флаг database-expert): на конфликте обновляем ВСЕ
мутабельные колонки (units/area/expected_online_date/confidence/method/computed_at),
иначе при ре-прогоне в таблице останутся stale-значения. Ключевые колонки (layer,
district_name, complex_id, obj_class, dev_group_name, source, snapshot_date) НЕ трогаем.
Mirror conventions (cbr_macro_sync.py / refresh_quarter_price_index.py):
• ``SessionLocal()`` + try/finally close, ``logger`` (не print).
• SAVEPOINT per-row (``with db.begin_nested():``, backend.md) — одна битая строка
(напр. CHECK-violation) не откатывает весь батч; commit один раз в конце.
• ``CAST(:x AS type)`` — НИКОГДА ``:x::type`` (psycopg v3).
• Сбой одного района (сервис уже degrade'ит до [], но страхуемся) не валит батч.
Coherent snapshot: ``run_date = date.today()`` вычисляется ОДИН раз в начале и
передаётся как ``snapshot_date`` КАЖДОЙ строке всех районов — весь батч делит один
снапшот (нет split на midnight-boundary; ON CONFLICT идемпотентен для same-day
re-run). Расписание — еженедельно, регистрируется в beat_schedule.py
(supply-layers-refresh-weekly).
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.site_finder.supply_layers import (
SupplyLayerRow,
compute_all_layers,
)
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Регион данных (ЕКБ / Свердловская обл.). domrf_kn_objects.district_name заполнен
# spatial-join'ом только для region_cd=66 (м.57) — зеркало supply_layers._EKB_REGION_CODE.
_EKB_REGION_CODE: int = 66
# Множество районов для прогона = UNION distinct non-NULL районов из ОБОИХ источников
# (objective_lots.district + domrf_kn_objects.district_name WHERE region_cd=66). Не
# хардкодим 8 имён — робастно к новым районам. CAST(:x AS type) (psycopg v3).
_DISTRICTS_SQL = text(
"""
SELECT district_name FROM (
SELECT DISTINCT ol.district AS district_name
FROM objective_lots ol
WHERE ol.district IS NOT NULL
UNION
SELECT DISTINCT o.district_name AS district_name
FROM domrf_kn_objects o
WHERE o.district_name IS NOT NULL
AND o.region_cd = CAST(:region_cd AS integer)
) d
ORDER BY district_name
"""
)
# UPSERT в supply_layers (м.125 + м.128). Конфликт по uq_supply_layers_logical (NULLS
# NOT DISTINCT) → ON CONFLICT по точному column-list ключа. DO UPDATE обновляет ВСЕ
# мутабельные колонки + computed_at=now() (database-expert: иначе stale-данные).
# Ключевые колонки (layer/district_name/complex_id/obj_class/dev_group_name/source/
# snapshot_date) НЕ обновляются — это и есть ключ. dev_group_name добавлен в ключ м.128
# (#970): без него все dev_group'ы района схлопывались в один NULL-key (complex_id всегда
# NULL для L3) → UPSERT перезатирал их (~95.6% потерь L3). CAST(:x AS type) везде
# (psycopg v3, никогда ::).
_UPSERT_SQL = text(
"""
INSERT INTO supply_layers (
layer, district_name, complex_id, obj_class, dev_group_name,
units_estimate, area_estimate, expected_online_date,
source, confidence, method, snapshot_date
) VALUES (
CAST(:layer AS smallint),
CAST(:district_name AS text),
CAST(:complex_id AS bigint),
CAST(:obj_class AS text),
CAST(:dev_group_name AS text),
CAST(:units_estimate AS integer),
CAST(:area_estimate AS numeric),
CAST(:expected_online_date AS date),
CAST(:source AS text),
CAST(:confidence AS text),
CAST(:method AS text),
CAST(:snapshot_date AS date)
)
ON CONFLICT (
layer, district_name, complex_id, obj_class, dev_group_name, source, snapshot_date
)
DO UPDATE SET
units_estimate = EXCLUDED.units_estimate,
area_estimate = EXCLUDED.area_estimate,
expected_online_date = EXCLUDED.expected_online_date,
confidence = EXCLUDED.confidence,
method = EXCLUDED.method,
computed_at = now()
"""
)
def _list_districts(db: Session) -> list[str]:
"""Distinct non-NULL районы из objective_lots + domrf_kn_objects (region_cd=66).
UNION обоих источников — робастно к новым районам (не хардкодим 8 имён). На сбое
запроса логируем и возвращаем [] (батч завершится no-op, не упадёт).
"""
try:
rows = db.execute(_DISTRICTS_SQL, {"region_cd": _EKB_REGION_CODE}).mappings().all()
except Exception:
logger.exception("supply_layers_refresh: districts enumeration query failed")
return []
return [r["district_name"] for r in rows if r["district_name"]]
def _upsert_rows(db: Session, rows: list[SupplyLayerRow]) -> tuple[int, int]:
"""UPSERT строк слоёв в supply_layers. SAVEPOINT per-row (backend.md): одна битая
строка (напр. CHECK-violation) не откатывает весь батч.
Returns: (upserted, skipped) — upserted = успешные INSERT/UPDATE, skipped = строки,
чей UPSERT упал (залогированы warning'ом). Commit делает caller (один на батч).
"""
upserted = 0
skipped = 0
for row in rows:
params = row.as_dict()
try:
with db.begin_nested():
db.execute(_UPSERT_SQL, params)
upserted += 1
except Exception as e:
skipped += 1
logger.warning(
"supply_layers upsert failed (layer=%s district=%s source=%s): %s",
row.layer,
row.district_name,
row.source,
e,
)
return upserted, skipped
@celery_app.task(
bind=True,
name="tasks.supply_layers_refresh.supply_layers_refresh",
max_retries=2,
)
def supply_layers_refresh(self: Any) -> dict[str, Any]:
"""Пересчитать 3-слойный склад предложения по всем районам и UPSERT в supply_layers.
Coherent snapshot: ``run_date = date.today()`` фиксируется ОДИН раз и передаётся
как ``snapshot_date`` каждой строке всех районов (избегает midnight-split, делает
same-day re-run идемпотентным). Цикл по районам: сбой/пустота сервиса одного района
не валит остальные (сервис уже degrade'ит до [], плюс per-district guard). Commit
один раз в конце (outer tx); per-row SAVEPOINT изолирует битые строки.
Returns:
Счётчики ``{"snapshot_date", "districts", "rows", "upserted", "skipped"}``.
Raises:
Пробрасывает только инфраструктурные сбои (commit / нефатальные внутри уже
обработаны) — чтобы фейл был виден в Celery / GlitchTip, а не молча проглочен.
"""
run_date = date.today()
db = SessionLocal()
try:
districts = _list_districts(db)
logger.info(
"supply_layers_refresh: %d districts to process (snapshot_date=%s)",
len(districts),
run_date.isoformat(),
)
all_rows: list[SupplyLayerRow] = []
for district in districts:
try:
rows = compute_all_layers(db, district=district, snapshot_date=run_date)
except Exception:
# Сервис уже graceful (→ []), но страхуемся: сбой одного района не
# должен ронять весь батч. Логируем и продолжаем со следующим.
logger.exception(
"supply_layers_refresh: compute_all_layers failed for district=%s",
district,
)
continue
all_rows.extend(rows)
upserted, skipped = _upsert_rows(db, all_rows)
db.commit()
logger.info(
"supply_layers_refresh: done snapshot_date=%s districts=%d rows=%d "
"upserted=%d skipped=%d",
run_date.isoformat(),
len(districts),
len(all_rows),
upserted,
skipped,
)
return {
"snapshot_date": run_date.isoformat(),
"districts": len(districts),
"rows": len(all_rows),
"upserted": upserted,
"skipped": skipped,
}
except Exception as e:
logger.exception("supply_layers_refresh failed: %s", e)
raise
finally:
db.close()