"""Refresh listings_search_mv materialized view (daily 3 AM). Currently tradein-mvp has no Celery infrastructure (verified — no celery_app.py exists in tradein-mvp/backend/app/). This module provides both: - A Celery task wrapper (active only if celery is installed and celery_app present). - A plain async refresh_search_matview() callable for cron / one-shot invocation. NEEDS COORDINATION: main session must decide whether to bootstrap Celery in tradein-mvp or run via external scheduler (systemd timer / OS cron). Until then, no Beat schedule. """ from __future__ import annotations import logging import time import psycopg from app.core.config import settings logger = logging.getLogger(__name__) def refresh_search_matview() -> None: """REFRESH MATERIALIZED VIEW CONCURRENTLY listings_search_mv + premium_houses. psycopg v3, sync. Logs duration. Idempotent. Safe to run during read traffic (CONCURRENTLY). premium_houses (#2002) derives from `listings`, so it is refreshed right after the search MV in the same session. Each MV is refreshed independently — a failure on one is logged and does not abort the other (autocommit, so a failed REFRESH does not leave the connection in an aborted transaction). """ start = time.monotonic() # DATABASE_URL is SQLAlchemy dialect form (postgresql+psycopg://...) in tradein-mvp # (see tradein-mvp/docker-compose.prod.yml). libpq / psycopg.connect() accepts only # postgresql:// or postgres:// — strip the +psycopg dialect prefix. dsn = settings.database_url.replace("postgresql+psycopg://", "postgresql://", 1) # CONCURRENTLY requires a UNIQUE index on each MV (listings_search_mv, # premium_houses_house_id_uidx). Order matters: premium_houses depends on the same # `listings` rows, so refresh it after the search MV is fresh. matviews = ("listings_search_mv", "premium_houses") with psycopg.connect(dsn, autocommit=True) as conn: for mv in matviews: mv_start = time.monotonic() try: with conn.cursor() as cur: cur.execute(f"REFRESH MATERIALIZED VIEW CONCURRENTLY {mv}") except Exception: # Don't let one MV's failure skip the others; surface for alerting. logger.exception("%s refresh failed", mv) else: logger.info("%s refresh completed in %.2fs", mv, time.monotonic() - mv_start) logger.info("matview refresh batch completed in %.2fs", time.monotonic() - start) # Celery-task wrapper — only registers if celery_app exists. try: from app.celery_app import celery_app # type: ignore[import-not-found] except ImportError: logger.info( "Celery not configured in tradein-mvp — refresh_search_matview() callable directly. " "Bootstrap app/celery_app.py to enable scheduled execution.", ) else: @celery_app.task(name="tradein.refresh_search_matview") # type: ignore[misc] def refresh_search_matview_task() -> None: """Celery wrapper for refresh_search_matview().""" refresh_search_matview() # ============================================================================= # Beat schedule snippet — paste into app/celery_app.py if/when Celery is bootstrapped: # # from celery.schedules import crontab # # celery_app.conf.beat_schedule = { # 'tradein-refresh-matview': { # 'task': 'tradein.refresh_search_matview', # 'schedule': crontab(hour=3, minute=0), # }, # # ... other tradein beat entries # } # # Per master plan sec 10.1 ('refresh-search-matview-daily': crontab(minute=0, hour=3)). # =============================================================================