Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""
|
|
Oracle Database Connection Pool - Multi-Server Support for ROA2WEB
|
|
|
|
Supports both single-server (backward compatible) and multi-server configurations.
|
|
Pool-uri sunt create lazy (la prima conexiune pe fiecare server) pentru optimizare.
|
|
"""
|
|
import asyncio
|
|
import oracledb
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from typing import Optional, Dict, Any
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OracleMultiPool:
|
|
"""
|
|
Multi-tenant Oracle connection pool manager.
|
|
|
|
Supports:
|
|
- Multiple Oracle servers with separate pools: {server_id: pool}
|
|
- Lazy pool creation (created on first connection)
|
|
- Backward compatibility (default server when no server_id specified)
|
|
- Graceful shutdown of all pools
|
|
"""
|
|
_instance: Optional['OracleMultiPool'] = None
|
|
_pools: Dict[str, oracledb.ConnectionPool]
|
|
_pool_configs: Dict[str, Dict[str, Any]]
|
|
_pool_lock: asyncio.Lock
|
|
_legacy_pool: Optional[oracledb.ConnectionPool] # For backward compatibility
|
|
_initialized: bool
|
|
|
|
def __new__(cls):
|
|
if cls._instance is None:
|
|
cls._instance = super(OracleMultiPool, cls).__new__(cls)
|
|
cls._instance._pools = {}
|
|
cls._instance._pool_configs = {}
|
|
cls._instance._pool_lock = asyncio.Lock()
|
|
cls._instance._legacy_pool = None
|
|
cls._instance._initialized = False
|
|
return cls._instance
|
|
|
|
async def initialize(self, **config):
|
|
"""
|
|
Initialize pool manager.
|
|
|
|
For backward compatibility, this can:
|
|
1. Create a legacy single pool (if called with individual params)
|
|
2. Just mark as initialized (if using lazy multi-pool loading)
|
|
"""
|
|
if self._initialized:
|
|
logger.debug("Pool manager already initialized")
|
|
return
|
|
|
|
# Check if we have DSN or individual parameters (legacy mode)
|
|
dsn = config.get('dsn', os.getenv('ORACLE_DSN'))
|
|
user = config.get('user', os.getenv('ORACLE_USER'))
|
|
|
|
if dsn or user:
|
|
# Legacy single-pool mode - create pool immediately
|
|
await self._create_legacy_pool(config)
|
|
|
|
self._initialized = True
|
|
logger.info("Oracle pool manager initialized")
|
|
|
|
async def _create_legacy_pool(self, config: Dict[str, Any]) -> None:
|
|
"""Create legacy single pool for backward compatibility."""
|
|
dsn = config.get('dsn', os.getenv('ORACLE_DSN'))
|
|
if dsn:
|
|
# Use DSN connection
|
|
self._legacy_pool = oracledb.create_pool(
|
|
user=config.get('user', os.getenv('ORACLE_USER')),
|
|
password=config.get('password', os.getenv('ORACLE_PASSWORD')),
|
|
dsn=dsn,
|
|
min=config.get('min_connections', 2),
|
|
max=config.get('max_connections', 10),
|
|
increment=config.get('increment', 1),
|
|
getmode=oracledb.POOL_GETMODE_WAIT
|
|
)
|
|
else:
|
|
# Use individual parameters (host, port, service_name or sid)
|
|
service_name = config.get('service_name', os.getenv('ORACLE_SERVICE_NAME'))
|
|
sid = config.get('sid', os.getenv('ORACLE_SID'))
|
|
|
|
pool_params = {
|
|
'user': config.get('user', os.getenv('ORACLE_USER')),
|
|
'password': config.get('password', os.getenv('ORACLE_PASSWORD')),
|
|
'host': config.get('host', os.getenv('ORACLE_HOST', 'localhost')),
|
|
'port': config.get('port', int(os.getenv('ORACLE_PORT', '1526'))),
|
|
'min': config.get('min_connections', 2),
|
|
'max': config.get('max_connections', 10),
|
|
'increment': config.get('increment', 1),
|
|
'getmode': oracledb.POOL_GETMODE_WAIT
|
|
}
|
|
|
|
if service_name:
|
|
pool_params['service_name'] = service_name
|
|
logger.info(f"Using SERVICE_NAME: {service_name}")
|
|
elif sid:
|
|
pool_params['sid'] = sid
|
|
logger.info(f"Using SID: {sid}")
|
|
else:
|
|
pool_params['service_name'] = 'ROA'
|
|
logger.info("Using default SERVICE_NAME: ROA")
|
|
|
|
self._legacy_pool = oracledb.create_pool(**pool_params)
|
|
|
|
logger.info(f"Legacy Oracle pool created with {self._legacy_pool.opened} connections")
|
|
|
|
def register_server(
|
|
self,
|
|
server_id: str,
|
|
host: str,
|
|
port: int,
|
|
user: str,
|
|
password: str,
|
|
sid: Optional[str] = None,
|
|
service_name: Optional[str] = None,
|
|
min_connections: int = 2,
|
|
max_connections: int = 10,
|
|
**kwargs
|
|
) -> None:
|
|
"""
|
|
Register a server configuration for lazy pool creation.
|
|
|
|
Pool will be created on first get_connection(server_id) call.
|
|
"""
|
|
self._pool_configs[server_id] = {
|
|
'host': host,
|
|
'port': port,
|
|
'user': user,
|
|
'password': password,
|
|
'sid': sid,
|
|
'service_name': service_name,
|
|
'min_connections': min_connections,
|
|
'max_connections': max_connections,
|
|
}
|
|
logger.info(f"Registered server '{server_id}' ({host}:{port}) for lazy pool creation")
|
|
|
|
async def _get_or_create_pool(self, server_id: str) -> oracledb.ConnectionPool:
|
|
"""
|
|
Get existing pool or create new one (lazy loading).
|
|
|
|
Thread-safe: uses asyncio.Lock to prevent duplicate pool creation.
|
|
"""
|
|
# Fast path: pool already exists
|
|
if server_id in self._pools:
|
|
return self._pools[server_id]
|
|
|
|
# Slow path: need to create pool
|
|
async with self._pool_lock:
|
|
# Double-check after acquiring lock
|
|
if server_id in self._pools:
|
|
return self._pools[server_id]
|
|
|
|
# Check if server is registered
|
|
if server_id not in self._pool_configs:
|
|
raise ValueError(f"Server '{server_id}' not registered. Call register_server() first.")
|
|
|
|
config = self._pool_configs[server_id]
|
|
logger.info(f"Creating pool for server '{server_id}' (lazy initialization)...")
|
|
|
|
pool_params = {
|
|
'user': config['user'],
|
|
'password': config['password'],
|
|
'host': config['host'],
|
|
'port': config['port'],
|
|
'min': config['min_connections'],
|
|
'max': config['max_connections'],
|
|
'increment': 1,
|
|
'getmode': oracledb.POOL_GETMODE_WAIT
|
|
}
|
|
|
|
if config.get('service_name'):
|
|
pool_params['service_name'] = config['service_name']
|
|
elif config.get('sid'):
|
|
pool_params['sid'] = config['sid']
|
|
else:
|
|
pool_params['service_name'] = 'ROA'
|
|
|
|
pool = oracledb.create_pool(**pool_params)
|
|
self._pools[server_id] = pool
|
|
|
|
logger.info(f"Pool created for server '{server_id}' with {pool.opened} connections")
|
|
return pool
|
|
|
|
@asynccontextmanager
|
|
async def get_connection(self, server_id: Optional[str] = None):
|
|
"""
|
|
Context manager pentru obținerea unei conexiuni din pool.
|
|
|
|
Args:
|
|
server_id: ID-ul serverului. Dacă None, folosește legacy pool sau default.
|
|
|
|
Usage:
|
|
# Multi-server mode
|
|
async with oracle_pool.get_connection('romfast') as conn:
|
|
...
|
|
|
|
# Backward compatible (legacy single pool)
|
|
async with oracle_pool.get_connection() as conn:
|
|
...
|
|
"""
|
|
connection = None
|
|
pool = None
|
|
|
|
try:
|
|
if server_id is None:
|
|
# Backward compatibility: use legacy pool
|
|
if self._legacy_pool is None:
|
|
# If no legacy pool, try to use 'default' server
|
|
if 'default' in self._pool_configs:
|
|
pool = await self._get_or_create_pool('default')
|
|
else:
|
|
raise RuntimeError(
|
|
"No pool available. Either initialize() with config "
|
|
"or register_server() with server_id='default'."
|
|
)
|
|
else:
|
|
pool = self._legacy_pool
|
|
else:
|
|
pool = await self._get_or_create_pool(server_id)
|
|
|
|
connection = pool.acquire()
|
|
logger.debug(f"Connection acquired from pool (server_id={server_id})")
|
|
yield connection
|
|
|
|
finally:
|
|
if connection is not None:
|
|
connection.close()
|
|
logger.debug(f"Connection returned to pool (server_id={server_id})")
|
|
|
|
async def execute_query(self, query: str, parameters=None, server_id: Optional[str] = None):
|
|
"""
|
|
Execute a SQL query and return all results.
|
|
|
|
Args:
|
|
query: SQL query string
|
|
parameters: Query parameters (dict or tuple)
|
|
server_id: Server ID for multi-pool mode (optional)
|
|
"""
|
|
async with self.get_connection(server_id) as connection:
|
|
logger.debug(f"Executing query on server '{server_id}': {query[:100]}...")
|
|
|
|
with connection.cursor() as cursor:
|
|
if parameters:
|
|
cursor.execute(query, parameters)
|
|
else:
|
|
cursor.execute(query)
|
|
|
|
# Check if this is a SELECT statement
|
|
if query.strip().upper().startswith('SELECT') or query.strip().upper().startswith('WITH'):
|
|
return cursor.fetchall()
|
|
else:
|
|
# For DML statements, return affected row count
|
|
connection.commit()
|
|
return cursor.rowcount
|
|
|
|
async def close_pool(self, server_id: Optional[str] = None):
|
|
"""
|
|
Close a specific pool or all pools.
|
|
|
|
Args:
|
|
server_id: Close specific pool. If None, close all pools.
|
|
"""
|
|
if server_id is not None:
|
|
# Close specific pool
|
|
if server_id in self._pools:
|
|
self._pools[server_id].close()
|
|
del self._pools[server_id]
|
|
logger.info(f"Closed pool for server '{server_id}'")
|
|
else:
|
|
# Close all pools (graceful shutdown)
|
|
if self._legacy_pool is not None:
|
|
self._legacy_pool.close()
|
|
self._legacy_pool = None
|
|
logger.info("Closed legacy pool")
|
|
|
|
for srv_id, pool in list(self._pools.items()):
|
|
pool.close()
|
|
logger.info(f"Closed pool for server '{srv_id}'")
|
|
|
|
self._pools.clear()
|
|
self._initialized = False
|
|
logger.info("All Oracle pools closed")
|
|
|
|
def get_pool_stats(self, server_id: Optional[str] = None) -> Dict[str, Any]:
|
|
"""
|
|
Get statistics for pool(s).
|
|
|
|
Args:
|
|
server_id: Get stats for specific server. If None, get all stats.
|
|
|
|
Returns:
|
|
Dict with pool statistics (opened, busy, min, max connections)
|
|
"""
|
|
stats = {}
|
|
|
|
if server_id is not None:
|
|
pool = self._pools.get(server_id)
|
|
if pool:
|
|
stats[server_id] = {
|
|
'opened': pool.opened,
|
|
'busy': pool.busy,
|
|
'min': pool.min,
|
|
'max': pool.max,
|
|
}
|
|
else:
|
|
# All pools including legacy
|
|
if self._legacy_pool:
|
|
stats['legacy'] = {
|
|
'opened': self._legacy_pool.opened,
|
|
'busy': self._legacy_pool.busy,
|
|
'min': self._legacy_pool.min,
|
|
'max': self._legacy_pool.max,
|
|
}
|
|
|
|
for srv_id, pool in self._pools.items():
|
|
stats[srv_id] = {
|
|
'opened': pool.opened,
|
|
'busy': pool.busy,
|
|
'min': pool.min,
|
|
'max': pool.max,
|
|
}
|
|
|
|
return stats
|
|
|
|
def is_server_registered(self, server_id: str) -> bool:
|
|
"""Check if a server is registered (config exists)."""
|
|
return server_id in self._pool_configs
|
|
|
|
def is_pool_active(self, server_id: str) -> bool:
|
|
"""Check if a pool is active (created) for a server."""
|
|
return server_id in self._pools
|
|
|
|
def get_registered_servers(self) -> list:
|
|
"""Get list of registered server IDs."""
|
|
return list(self._pool_configs.keys())
|
|
|
|
def get_active_pools(self) -> list:
|
|
"""Get list of server IDs with active pools."""
|
|
return list(self._pools.keys())
|
|
|
|
|
|
# Backward compatibility: keep old class name as alias
|
|
OraclePool = OracleMultiPool
|
|
|
|
# Instance globală pentru folosire în toate aplicațiile
|
|
oracle_pool = OracleMultiPool()
|