Aug 20 - Session tenant binding
This commit is contained in:
@@ -5,40 +5,75 @@ 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.
|
||||
backend connections ≈ workers × cached-tenants × (pool_size + max_overflow),
|
||||
so the cache is BOUNDED (MT-21): it holds at most
|
||||
``TENANT_ENGINE_CACHE_MAX`` engines and evicts the least-recently-used one
|
||||
beyond that, disposing it. Without the bound, a process that has served N
|
||||
tenants holds N pools forever, and the connection count grows without limit
|
||||
until MySQL's ``max_connections`` (default 151) rejects new sessions.
|
||||
|
||||
Worked example — 8 workers, pool_size 2 + max_overflow 3, cache cap 32:
|
||||
8 × 32 × 5 = 1280 worst case, vs. unbounded before this change.
|
||||
|
||||
Eviction disposes the engine, which closes its *idle* pooled connections.
|
||||
Connections already checked out by another thread stay valid and are closed
|
||||
when returned, so eviction is safe under concurrency; the evicted tenant
|
||||
simply rebuilds its engine on the next request.
|
||||
|
||||
`invalidate(tenant_id)` drops a cached engine (e.g. after credential rotation
|
||||
or tenant suspension); the next request rebuilds it.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
_engines = {}
|
||||
_engines = OrderedDict()
|
||||
_lock = threading.Lock()
|
||||
|
||||
# Fallback cap used when no application context is available (CLI/cron paths
|
||||
# that touch the cache outside a request). Mirrors the config default.
|
||||
_DEFAULT_CACHE_MAX = 32
|
||||
|
||||
|
||||
def _cache_max():
|
||||
try:
|
||||
return int(current_app.config.get('TENANT_ENGINE_CACHE_MAX', _DEFAULT_CACHE_MAX))
|
||||
except Exception:
|
||||
return _DEFAULT_CACHE_MAX
|
||||
|
||||
|
||||
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
|
||||
evicted = []
|
||||
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_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 2),
|
||||
max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 3),
|
||||
pool_recycle=current_app.config.get('TENANT_ENGINE_POOL_RECYCLE', 1800),
|
||||
future=True,
|
||||
)
|
||||
_engines[tenant.id] = engine
|
||||
# Bound the cache: drop least-recently-used engines beyond the cap.
|
||||
cap = _cache_max()
|
||||
while cap > 0 and len(_engines) > cap:
|
||||
old_id, old_engine = _engines.popitem(last=False)
|
||||
evicted.append((old_id, old_engine))
|
||||
else:
|
||||
_engines.move_to_end(tenant.id)
|
||||
|
||||
# Dispose outside the lock — dispose() can block on socket teardown.
|
||||
for old_id, old_engine in evicted:
|
||||
try:
|
||||
old_engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
return engine
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user