""" 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()