- move the relational data center to MySQL and a standalone workbench\n- add Interface Center API credentials, policies, logs, and DBeaver SSH guidance\n- harden authentication and deployment while retiring unused management surfaces
156 lines
5.0 KiB
Python
156 lines
5.0 KiB
Python
"""Async MySQL connection management for the independent Data Center.
|
|
|
|
The application still uses PostgreSQL for accounts and graph metadata. This
|
|
module owns a separate MySQL pool whose default database is the protected
|
|
``platform_control`` catalog. Business databases are selected explicitly for
|
|
the duration of a request and reset before the pooled connection is returned.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from contextlib import asynccontextmanager
|
|
import logging
|
|
import re
|
|
from typing import AsyncIterator
|
|
from urllib.parse import unquote, urlparse
|
|
|
|
import aiomysql
|
|
|
|
from app.config import settings
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_DATABASE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
|
_pool: aiomysql.Pool | None = None
|
|
_control_database = "platform_control"
|
|
_pool_generation = 0
|
|
|
|
|
|
class DataCenterUnavailable(RuntimeError):
|
|
"""Raised when the independent MySQL service is not connected."""
|
|
|
|
|
|
def _connection_options(*, include_database: bool) -> dict[str, object]:
|
|
parsed = urlparse(settings.data_mysql_url)
|
|
if parsed.scheme not in {"mysql", "mysql+aiomysql"}:
|
|
raise ValueError("DATA_MYSQL_URL 必须使用 mysql:// 协议")
|
|
database = unquote(parsed.path.lstrip("/")) or "platform_control"
|
|
if not _DATABASE_PATTERN.fullmatch(database):
|
|
raise ValueError("DATA_MYSQL_URL 中的控制数据库名称不合法")
|
|
|
|
global _control_database
|
|
_control_database = database
|
|
options: dict[str, object] = {
|
|
"host": parsed.hostname or "localhost",
|
|
"port": parsed.port or 3306,
|
|
"user": unquote(parsed.username or "root"),
|
|
"password": unquote(parsed.password or ""),
|
|
"charset": "utf8mb4",
|
|
"autocommit": False,
|
|
"cursorclass": aiomysql.DictCursor,
|
|
}
|
|
if include_database:
|
|
options["db"] = database
|
|
return options
|
|
|
|
|
|
def control_database_name() -> str:
|
|
return _control_database
|
|
|
|
|
|
def data_pool_available() -> bool:
|
|
return _pool is not None
|
|
|
|
|
|
def data_pool_generation() -> int:
|
|
return _pool_generation
|
|
|
|
|
|
async def init_data_pool() -> bool:
|
|
"""Create the Data Center catalog and pool.
|
|
|
|
Development can start without MySQL when ``DATA_MYSQL_REQUIRED=false`` so
|
|
graph-only pages remain available. Data Center endpoints then return 503
|
|
instead of silently falling back to PostgreSQL.
|
|
"""
|
|
|
|
global _pool, _pool_generation
|
|
if _pool is not None:
|
|
return True
|
|
try:
|
|
bootstrap = _connection_options(include_database=False)
|
|
bootstrap["autocommit"] = True
|
|
conn = await aiomysql.connect(**bootstrap)
|
|
try:
|
|
async with conn.cursor() as cur:
|
|
await cur.execute(
|
|
"SELECT SCHEMA_NAME FROM information_schema.SCHEMATA "
|
|
"WHERE SCHEMA_NAME=%s",
|
|
(_control_database,),
|
|
)
|
|
if not await cur.fetchone():
|
|
await cur.execute(
|
|
f"CREATE DATABASE `{_control_database}` "
|
|
"CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
options = _connection_options(include_database=True)
|
|
_pool = await aiomysql.create_pool(
|
|
**options,
|
|
minsize=max(1, settings.data_mysql_pool_min_size),
|
|
maxsize=max(
|
|
settings.data_mysql_pool_min_size,
|
|
settings.data_mysql_pool_max_size,
|
|
),
|
|
pool_recycle=1800,
|
|
)
|
|
_pool_generation += 1
|
|
logger.info("Data Center MySQL pool connected")
|
|
return True
|
|
except Exception:
|
|
_pool = None
|
|
if settings.data_mysql_required:
|
|
raise
|
|
logger.warning(
|
|
"Data Center MySQL is unavailable; Data Center/API Center routes "
|
|
"will return 503",
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
|
|
async def close_data_pool() -> None:
|
|
global _pool, _pool_generation
|
|
if _pool is None:
|
|
return
|
|
_pool.close()
|
|
await _pool.wait_closed()
|
|
_pool = None
|
|
_pool_generation += 1
|
|
|
|
|
|
@asynccontextmanager
|
|
async def get_data_conn(database: str | None = None) -> AsyncIterator[aiomysql.Connection]:
|
|
if _pool is None:
|
|
raise DataCenterUnavailable("数据中心 MySQL 服务尚未连接")
|
|
if database is not None and not _DATABASE_PATTERN.fullmatch(database):
|
|
raise ValueError("MySQL 数据库名称不合法")
|
|
|
|
async with _pool.acquire() as conn:
|
|
selected = database or _control_database
|
|
await conn.select_db(selected)
|
|
try:
|
|
yield conn
|
|
except Exception:
|
|
await conn.rollback()
|
|
raise
|
|
finally:
|
|
# Prevent a later request from inheriting a business database.
|
|
try:
|
|
await conn.rollback()
|
|
if selected != _control_database:
|
|
await conn.select_db(_control_database)
|
|
except Exception:
|
|
logger.warning("Failed to reset a Data Center MySQL connection", exc_info=True)
|