Files
roa2web-service-auto/shared/database/oracle_pool.py
Claude Agent b137e80b71 feat: multi-Oracle server support with runtime switching
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>
2026-01-26 22:39:06 +00:00

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