"""Gunicorn runtime config. Workers import Flask themselves so `HUP` can reload bind-mounted Python files without restarting the app container. If preload is re-enabled, hot reload will restart workers but keep the preloaded app object from the old master process. """ import os import sys import threading from sqlalchemy.engine import Engine bind = "0.0.0.0:80" workers = int(os.getenv("WEB_CONCURRENCY", "4")) worker_class = os.getenv("GUNICORN_WORKER_CLASS", "gthread") threads = int(os.getenv("GUNICORN_THREADS", "4")) timeout = int(os.getenv("GUNICORN_TIMEOUT", "300")) accesslog = "-" errorlog = "-" preload_app = False def _dispose_engine(engine, label, server): try: try: engine.dispose(close=False) except TypeError: engine.dispose() server.log.info("Disposed inherited SQLAlchemy engine after fork: %s", label) return True except Exception: server.log.exception("Failed disposing inherited SQLAlchemy engine: %s", label) return False def post_fork(server, worker): """Reset DB pools inherited from the preloaded master process. SQLAlchemy engines are safe to keep as objects after fork, but their pools must be replaced so workers do not share PostgreSQL TCP sockets. """ disposed_ids = set() disposed_count = 0 prefixes = ("app", "database.", "routes.", "services.") def dispose_once(engine, label): nonlocal disposed_count engine_id = id(engine) if engine_id in disposed_ids: return disposed_ids.add(engine_id) if _dispose_engine(engine, label, server): disposed_count += 1 for module_name, module in list(sys.modules.items()): if module is None or not module_name.startswith(prefixes): continue for attr_name, value in vars(module).items(): candidates = [] if isinstance(value, Engine): candidates.append((value, f"{module_name}.{attr_name}")) else: try: engine = getattr(value, "engine", None) except Exception: # Flask/Werkzeug LocalProxy objects may require a request # context when inspected. They cannot own global DB pools. continue if isinstance(engine, Engine): candidates.append((engine, f"{module_name}.{attr_name}.engine")) for engine, label in candidates: dispose_once(engine, label) manager_module = sys.modules.get("database.manager") manager_class = getattr(manager_module, "DatabaseManager", None) manager_cache = getattr(manager_class, "_instance_cache", {}) if manager_class else {} for cache_key, cached in list(manager_cache.items()): if not isinstance(cached, dict): continue engine = cached.get("engine") if isinstance(engine, Engine): dispose_once(engine, f"database.manager.DatabaseManager._instance_cache[{cache_key!r}]") server.log.info( "Worker %s reset %s SQLAlchemy engine pool(s) after preload fork", worker.pid, disposed_count, ) def post_worker_init(worker): """Load the shared dashboard cache in every worker before user traffic.""" enabled = os.getenv("DASHBOARD_PREWARM_ON_WORKER_INIT", "1").lower() if enabled in {"0", "false", "no"}: return def _warm_dashboard_cache(): try: from routes.dashboard_routes import warm_full_dashboard_cache warm_full_dashboard_cache(reason=f"gunicorn-worker-{worker.pid}") from routes.edm_routes import warm_promo_dashboard_cache warm_promo_dashboard_cache(reason=f"gunicorn-worker-{worker.pid}") except Exception as exc: worker.log.warning("Dashboard/promo cache prewarm failed in worker %s: %s", worker.pid, exc) thread = threading.Thread( target=_warm_dashboard_cache, name=f"dashboard-prewarm-{worker.pid}", daemon=True, ) thread.start() worker.log.info("Started dashboard cache prewarm thread in worker %s", worker.pid)