Files
life-echo/api/app/core/alembic_startup.py
2026-05-19 16:40:45 +08:00

118 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
启动时执行 Alembic 迁移(与 CLI `uv run alembic upgrade head` 等价)。
- 对瞬时连接错误重试(数据库尚未就绪、网络抖动)。
- 可通过 settings 关闭迁移、或失败时中止进程(生产推荐)。
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Final
from sqlalchemy.exc import OperationalError
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__name__)
_API_DIR: Final[Path] = Path(__file__).resolve().parent.parent.parent
def _repair_withdrawn_revision_stamp_if_needed() -> None:
from sqlalchemy import create_engine
from app.core.alembic_revision_repair import try_repair_withdrawn_0020_revision
from app.core.db import _database_url
engine = create_engine(_database_url())
with engine.connect() as conn:
if try_repair_withdrawn_0020_revision(conn):
conn.commit()
logger.warning(
"alembic_version 曾为已撤回的 0020_*,已回退到 0018"
"将重新执行 0019_align_legacy_schema"
)
def _run_alembic_upgrade_once() -> None:
from alembic.command import upgrade
from alembic.config import Config
_repair_withdrawn_revision_stamp_if_needed()
cfg = Config(str(_API_DIR / "alembic.ini"))
upgrade(cfg, "head")
def _is_retryable_migration_error(err: BaseException) -> bool:
if isinstance(err, OperationalError):
return True
msg = str(err).lower()
needles = (
"connection refused",
"could not connect",
"connection reset",
"timeout",
"server closed the connection",
"the database system is starting",
"database system is shutting down",
"too many connections",
)
return any(n in msg for n in needles)
def run_alembic_upgrade_at_startup() -> None:
"""
同步执行迁移;失败时按 settings 记录日志或抛出。
在 asyncio 中请使用 ``asyncio.to_thread(run_alembic_upgrade_at_startup)``
避免阻塞事件循环。
"""
if not settings.alembic_run_on_startup:
logger.info("跳过 Alembic 迁移alembic_run_on_startup=False")
return
max_tries = max(1, settings.alembic_startup_max_retries)
base_delay = float(settings.alembic_startup_retry_base_seconds)
last: BaseException | None = None
for attempt in range(max_tries):
try:
_run_alembic_upgrade_once()
if attempt > 0:
logger.info(
"Alembic 迁移成功(第 {} 次尝试)",
attempt + 1,
)
else:
logger.info("Alembic 迁移已就绪")
return
except Exception as e:
last = e
will_retry = attempt < max_tries - 1 and _is_retryable_migration_error(e)
if will_retry:
delay = base_delay * (2**attempt)
logger.warning(
"Alembic 迁移失败(将重试): {}{:.1f}s 后重试 ({}/{})",
e,
delay,
attempt + 1,
max_tries,
)
time.sleep(delay)
else:
break
assert last is not None
logger.exception("Alembic 迁移失败: {}", last)
if settings.alembic_startup_fail_fast:
raise RuntimeError(
"Alembic migration failed; set ALEMBIC_STARTUP_FAIL_FAST=false "
"for dev-only tolerate mode"
) from last
logger.error(
"应用将继续启动,但数据库结构可能未更新;请检查 DATABASE_URL 与 PostgreSQL 是否可达"
)