Files
Cloud-Tour-to-Libo/app/data_platform/mysql_db.py
T

197 lines
6.4 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:// 协议")
control_database = unquote(parsed.path.lstrip("/")) or "platform_control"
if not _DATABASE_PATTERN.fullmatch(control_database):
raise ValueError("DATA_MYSQL_URL 中的控制数据库名称不合法")
global _control_database
_control_database = control_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"] = control_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)
@asynccontextmanager
async def get_provisioner_conn() -> AsyncIterator[aiomysql.Connection]:
"""Open the isolated account-provisioning connection.
The normal Data Center account must not hold ``CREATE USER`` or ``GRANT
OPTION``. This short-lived connection is used only by the admin-only
DBeaver issuance and revocation endpoints.
"""
username = settings.data_mysql_provisioner_user.strip()
password = settings.data_mysql_provisioner_password
if not username or not password:
raise DataCenterUnavailable("DBeaver账号签发服务尚未配置")
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,31}", username):
raise ValueError("MySQL账号签发用户名称不合法")
parsed = urlparse(settings.data_mysql_url)
database = unquote(parsed.path.lstrip("/")) or "platform_control"
if not _DATABASE_PATTERN.fullmatch(database):
raise ValueError("DATA_MYSQL_URL 中的控制数据库名称不合法")
conn = await aiomysql.connect(
host=parsed.hostname or "localhost",
port=parsed.port or 3306,
user=username,
password=password,
charset="utf8mb4",
autocommit=False,
cursorclass=aiomysql.DictCursor,
)
try:
yield conn
except Exception:
await conn.rollback()
raise
finally:
try:
await conn.rollback()
finally:
conn.close()