"""Database engine factory.
Local dev: psycopg2 over TCP (no INSTANCE_UNIX_SOCKET set)
Cloud SQL: pg8000 over Unix socket (INSTANCE_UNIX_SOCKET set via app.yaml)
create_engine is lazy - it opens no connection until a session is used - so
importing this module never needs a live database. That is what lets the test
suite import the app and rebind db_session to an in-memory SQLite without a
Postgres anywhere in sight.
Usage:
from utils.db_wrapper import db_session
db_session.query(Organization).all() # inside a Flask request
"""
import logging
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
import settings
logger = logging.getLogger(__name__)
def db_factory():
cfg = settings.Config
if cfg.INSTANCE_UNIX_SOCKET:
logger.info("Creating Cloud SQL engine via Unix socket (pg8000)")
url = (
f"postgresql+pg8000://{cfg.DB_USER}:{cfg.DB_PASS}@/{cfg.DB_NAME}"
f"?unix_sock=/cloudsql/{cfg.INSTANCE_UNIX_SOCKET}/.s.PGSQL.5432"
)
else:
logger.info("Creating local PostgreSQL engine via TCP (psycopg2)")
url = (
f"postgresql+psycopg2://{cfg.DB_USER}:{cfg.DB_PASS}"
f"@localhost:{cfg.DB_PORT}/{cfg.DB_NAME}"
)
# Sized against the worker's thread count, and against a budget.
#
# Each gunicorn thread holds one connection while it works (see gunicorn.py),
# so the pool needs a slot per thread or threads queue on the pool instead of
# the database - the bottleneck moves without moving. One spare for a burst,
# and no more.
#
# This number and max_instances in app.yaml are ONE decision, not two:
#
# (pool_size + max_overflow) x workers x max_instances <= the instance's
# max_connections, minus whatever else shares that instance
#
# Cloud SQL's default max_connections is 100 and a small instance is usually
# shared. Raising either number without the other is what exhausts the
# database - for every app on the instance at once, not just this one. The
# SQLAlchemy default (5 + 10 overflow) is 15 per *process* and blows a shared
# budget at two instances.
threads = int(os.environ.get("WEB_THREADS", "4"))
return create_engine(url, pool_pre_ping=True,
pool_size=threads, max_overflow=1,
# Cloud SQL drops idle connections; recycling under that
# keeps a pooled one from being handed out already dead.
pool_recycle=1800)
db_engine = db_factory()
SessionLocal = sessionmaker(bind=db_engine)
# Request-scoped session. main.py calls db_session.remove() on app-context
# teardown, so each request gets a clean session and connections go back to
# the pool. Without that teardown, scoped_session leaks a connection per request
# until the pool is exhausted.
db_session = scoped_session(SessionLocal)