fix(analytics): address review-bot PR B fixes (#113)

SQL (data/sql/94_mv_layout_velocity.sql):
- 🟠 idempotency WARN comment перед DROP CASCADE
- 🟡 WITH NO DATA + REFRESH after CREATE INDEX (меньше lock duration)
- 🟡 WHERE cm.objective_group = 'Екатеринбург' (cross-region Cartesian защита)
- 🟡 COMMENT ON MATERIALIZED VIEW с источниками + refresh механизмом

Python (services/site_finder/layout_velocity_refresh.py):
- 🟡 db.commit() после REFRESH (visibility через connection pool)
- 🟡 убрать f-string в SQL — две явных text() ветки
- 🟡 try/except OperationalError fallback на non-concurrent
This commit is contained in:
lekss361 2026-05-16 11:17:37 +03:00
parent 144970aadd
commit 46c2a84ea4
2 changed files with 36 additions and 3 deletions

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@ -34,8 +35,22 @@ def refresh_layout_velocity(db: Session, *, concurrently: bool = True) -> int:
Returns:
Row count in the MV after refresh (for observability / alerting).
"""
mode = "CONCURRENTLY" if concurrently else ""
db.execute(text(f"REFRESH MATERIALIZED VIEW {mode} mv_layout_velocity"))
try:
if concurrently:
db.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity"))
else:
db.execute(text("REFRESH MATERIALIZED VIEW mv_layout_velocity"))
db.commit()
except OperationalError as e:
if concurrently and "cannot refresh materialized view" in str(e).lower():
logger.warning(
"CONCURRENTLY failed (MV likely not populated), falling back to non-concurrent"
)
db.rollback()
db.execute(text("REFRESH MATERIALIZED VIEW mv_layout_velocity"))
db.commit()
else:
raise
row = db.execute(text("SELECT COUNT(*) FROM mv_layout_velocity")).first()
count = int(row[0]) if row else 0
logger.info("mv_layout_velocity refreshed: %d rows", count)

View file

@ -22,6 +22,13 @@
--
-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking.
-- Dependencies: none (no views depend on this MV at creation time).
--
-- WARN: Re-apply этого файла (DR / lost _schema_migrations entry / dev local)
-- снесёт MV + CASCADE удалит зависимости (e.g. future /best-layouts views).
-- После re-apply ПЕРВЫЙ refresh должен быть concurrently=False
-- (CONCURRENTLY требует уже populated MV — упадёт на пустой).
-- Tracking table `_schema_migrations` нормально предотвращает re-apply,
-- но добавлен comment как safety reminder.
BEGIN;
@ -59,9 +66,12 @@ JOIN objective_complex_mapping cm
ON cm.objective_complex_name = l.project_name
WHERE l.room_bucket IS NOT NULL
AND cm.domrf_obj_id IS NOT NULL
GROUP BY cm.domrf_obj_id, l.room_bucket;
AND cm.objective_group = 'Екатеринбург' -- защита от cross-region Cartesian при future multi-city
GROUP BY cm.domrf_obj_id, l.room_bucket
WITH NO DATA;
-- UNIQUE index required for REFRESH CONCURRENTLY (future periodic refreshes)
-- Created on empty MV → instant, no lock duration on data
CREATE UNIQUE INDEX mv_layout_velocity_pk
ON mv_layout_velocity (obj_id, room_bucket);
@ -69,4 +79,12 @@ CREATE UNIQUE INDEX mv_layout_velocity_pk
CREATE INDEX mv_layout_velocity_obj_idx
ON mv_layout_velocity (obj_id);
-- Initial populate (non-concurrent — MV was just created, CONCURRENTLY not allowed on empty MV)
REFRESH MATERIALIZED VIEW mv_layout_velocity;
COMMENT ON MATERIALIZED VIEW mv_layout_velocity IS
'Per-(obj_id, room_bucket) deals aggregation за last 24 months. '
'Source: objective_corpus_room_month × objective_complex_mapping (EKB only). '
'Refresh via layout_velocity_refresh.py (concurrently=True after initial populate).';
COMMIT;