feat(analytics): mv_layout_velocity MV + refresh helper (#113 PR B) #194

Merged
lekss361 merged 2 commits from feat/113-mv-layout-velocity into main 2026-05-16 08:26:59 +00:00
2 changed files with 36 additions and 3 deletions
Showing only changes of commit 46c2a84ea4 - Show all commits

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;