From 46c2a84ea4214ec0c4d46ef776370eb168175082 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 11:17:37 +0300 Subject: [PATCH] fix(analytics): address review-bot PR B fixes (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../site_finder/layout_velocity_refresh.py | 19 ++++++++++++++++-- data/sql/94_mv_layout_velocity.sql | 20 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/backend/app/services/site_finder/layout_velocity_refresh.py b/backend/app/services/site_finder/layout_velocity_refresh.py index 8b102515..61075c60 100644 --- a/backend/app/services/site_finder/layout_velocity_refresh.py +++ b/backend/app/services/site_finder/layout_velocity_refresh.py @@ -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) diff --git a/data/sql/94_mv_layout_velocity.sql b/data/sql/94_mv_layout_velocity.sql index 6e841ad9..d67301e5 100644 --- a/data/sql/94_mv_layout_velocity.sql +++ b/data/sql/94_mv_layout_velocity.sql @@ -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;