117 lines
4.1 KiB
Python
117 lines
4.1 KiB
Python
"""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)
|