60 lines
1.9 KiB
Python
60 lines
1.9 KiB
Python
"""
|
||
app/tenancy/engine_cache.py
|
||
---------------------------
|
||
Process-local cache of per-tenant SQLAlchemy engines, keyed by tenant id.
|
||
|
||
Each tenant has its own database, hence its own engine + connection pool.
|
||
Engines are created lazily on first use and reused across requests. Total
|
||
backend connections ≈ workers × cached-tenants × pool_size, so pool sizing is
|
||
a real scaling lever (see MULTI_TENANT_PLAN.md §4); tune via config, or set a
|
||
small pool / switch to NullPool when the tenant count grows large.
|
||
|
||
`invalidate(tenant_id)` drops a cached engine (e.g. after credential rotation
|
||
or tenant suspension); the next request rebuilds it.
|
||
"""
|
||
|
||
import threading
|
||
|
||
from flask import current_app
|
||
from sqlalchemy import create_engine
|
||
|
||
_engines = {}
|
||
_lock = threading.Lock()
|
||
|
||
|
||
def get_tenant_engine(tenant):
|
||
"""Return (building if needed) the cached engine for a TenantContext."""
|
||
engine = _engines.get(tenant.id)
|
||
if engine is not None:
|
||
return engine
|
||
with _lock:
|
||
engine = _engines.get(tenant.id)
|
||
if engine is None:
|
||
engine = create_engine(
|
||
tenant.db_uri,
|
||
pool_pre_ping=True,
|
||
pool_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 5),
|
||
max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 5),
|
||
pool_recycle=current_app.config.get('TENANT_ENGINE_POOL_RECYCLE', 1800),
|
||
future=True,
|
||
)
|
||
_engines[tenant.id] = engine
|
||
return engine
|
||
|
||
|
||
def invalidate(tenant_id):
|
||
"""Drop and dispose a cached tenant engine, if present."""
|
||
with _lock:
|
||
engine = _engines.pop(tenant_id, None)
|
||
if engine is not None:
|
||
engine.dispose()
|
||
|
||
|
||
def clear():
|
||
"""Dispose and drop all cached engines (test/teardown helper)."""
|
||
with _lock:
|
||
engines = list(_engines.values())
|
||
_engines.clear()
|
||
for engine in engines:
|
||
engine.dispose()
|