alembic fix

This commit is contained in:
2026-06-01 16:34:07 +03:00
parent e12a9ddfce
commit 4c5da7e4d9
15 changed files with 346 additions and 90 deletions

View File

@@ -18,7 +18,6 @@ from pathlib import Path
# project_root is used for static files mounting
project_root = Path(__file__).resolve().parent.parent.parent
from alembic.config import Config as AlembicConfig
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
@@ -27,8 +26,6 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
from typing import Any
from alembic import command as alembic_command
from .api import auth
from .api.routes import (
admin,
@@ -67,10 +64,14 @@ from .models.auth import Role, User
# #region lifespan [C:3] [TYPE Function]
# @BRIEF Async context manager for FastAPI startup/shutdown lifecycle.
# @RELATION CALLS -> [run_alembic_migrations]
# @RELATION CALLS -> [init_db]
# @RELATION CALLS -> [AppDependencies]
# @POST On startup: schema up-to-date, admin exists, scheduler started. On shutdown: scheduler stopped.
# @POST On startup: admin exists, scheduler started. On shutdown: scheduler stopped.
# @RATIONALE Alembic migrations removed from lifespan — they now run exclusively
# in docker/backend.entrypoint.sh (wait_for_db → alembic upgrade head).
# Running migrations in both places added ~5s startup overhead and masked
# partial failures. init_db() remains as a safety net for tables without
# dedicated Alembic migrations (e.g., newly added models during development).
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
@@ -78,9 +79,7 @@ async def lifespan(app: FastAPI):
with belief_scope("startup_event"):
logger.info("🔐 Ensuring encryption key...")
ensure_encryption_key()
logger.info("📦 Running Alembic migrations...")
run_alembic_migrations()
logger.info("🗄️ Initializing database tables...")
logger.info("🗄️ Initializing database tables (safety net)...")
init_db()
logger.info("👤 Bootstrapping admin user...")
ensure_initial_admin_user()
@@ -109,7 +108,6 @@ async def lifespan(app: FastAPI):
scheduler = get_scheduler_service()
scheduler.start()
logger.info("✅ Application startup complete")
logger.info("✅ Application startup complete")
yield
# Shutdown
scheduler.stop()
@@ -185,13 +183,16 @@ def ensure_initial_admin_user() -> None:
# #endregion ensure_initial_admin_user
# #region run_alembic_migrations [C:2] [TYPE Function]
# @BRIEF Applies all pending Alembic migrations against DATABASE_URL.
# DEPRECATED: Migrations now run exclusively in docker/backend.entrypoint.sh.
# Kept for local development (manual invocation).
# @POST All Alembic migrations up to 'head' are applied.
# @SIDE_EFFECT Executes ALTER TABLE / CREATE TABLE via Alembic chain.
# @RATIONALE Single source of truth for schema changes. All column additions,
# drops, renames, and data migrations go through Alembic.
# init_db() runs after to create any tables that don't have
# a standalone Alembic migration yet.
# @DEPRECATED Migrations moved to entrypoint.sh — this function is kept for
# local development only. Do NOT call from lifespan or production code.
def run_alembic_migrations() -> None:
from alembic.config import Config as AlembicConfig
from alembic import command as alembic_command
with belief_scope("run_alembic_migrations"):
try:
alembic_cfg = AlembicConfig("alembic.ini")

View File

@@ -760,15 +760,17 @@ def _ensure_dictionary_entries_columns(bind_engine):
# #region init_db [C:3] [TYPE Function]
# @BRIEF Creates any missing tables via create_all().
# @BRIEF Creates any missing tables via create_all() — safety net for development.
# @PRE engine, tasks_engine and auth_engine are initialized.
# Alembic migrations (run_alembic_migrations) have already been applied.
# In Docker: Alembic migrations already applied via entrypoint.sh.
# @POST All tables from SQLAlchemy models exist in all databases.
# @SIDE_EFFECT Creates physical database tables if they don't exist.
# @RATIONALE Schema migrations are handled by Alembic (run_alembic_migrations runs
# before init_db in startup_event). create_all() only creates NEW tables
# that don't exist yet — it does NOT alter existing ones. All column
# additions must go through Alembic migrations.
# @RATIONALE create_all() is idempotent — it only creates tables that don't exist,
# never alters existing ones. In Docker, Alembic runs first in entrypoint.sh
# (wait_for_db → alembic upgrade head), so init_db() is a no-op for production.
# For local development (run.sh without Docker), this provides a safety net.
# @REJECTED Removing init_db() entirely was rejected — local development without
# Docker would require manual Alembic setup, increasing friction for developers.
# @REJECTED _ensure_*() inline additive migrations removed — they were duplicating
# Alembic logic, were not versioned, and created hidden schema drift.
# All schema changes must now go through Alembic.

View File

@@ -0,0 +1,114 @@
# #region check_migration_chain [C:3] [TYPE Module] [SEMANTICS alembic,migration,validation,ci]
# @BRIEF Validates Alembic migration chain integrity — checks for multiple heads,
# broken chains, and missing down_revision references.
# @RATIONALE Merge migrations (e.g., f0e9d8c7b6a5) are fragile — a new migration
# with wrong down_revision can create multiple heads and break `alembic upgrade head`.
# This script catches the issue before deployment.
# @REJECTED Relying on `alembic upgrade head` to fail at deploy time was rejected —
# it's better to catch migration chain issues in CI or pre-commit.
import sys
from pathlib import Path
# Ensure backend/src is importable
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# #region check_migration_heads [C:2] [TYPE Function]
# @BRIEF Checks that there is exactly one migration head.
# @POST Prints error and exits 1 if multiple heads found.
def check_migration_heads() -> None:
from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
alembic_cfg = AlembicConfig("alembic.ini")
alembic_cfg.set_main_option("script_location", "alembic")
script_dir = ScriptDirectory.from_config(alembic_cfg)
heads = list(script_dir.get_heads())
if len(heads) == 0:
print("[migration-check] ERROR: No migration heads found — chain is empty")
sys.exit(1)
if len(heads) > 1:
print(f"[migration-check] ERROR: Multiple migration heads detected ({len(heads)}):")
for h in heads:
rev = script_dir.get_revision(h)
print(f" - {h}: {rev.doc}")
print("[migration-check] Fix: ensure new migrations reference the correct parent")
sys.exit(1)
print(f"[migration-check] OK: Single migration head — {heads[0]}")
# #endregion check_migration_heads
# #region check_migration_chain_integrity [C:2] [TYPE Function]
# @BRIEF Walks the migration chain from head to root, checking all down_revision links exist.
# @POST Prints error and exits 1 if a broken link is found.
def check_migration_chain_integrity() -> None:
from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
alembic_cfg = AlembicConfig("alembic.ini")
alembic_cfg.set_main_option("script_location", "alembic")
script_dir = ScriptDirectory.from_config(alembic_cfg)
heads = list(script_dir.get_heads())
if not heads:
return # already checked above
visited = set()
current_revs = heads
while current_revs:
next_revs = []
for rev_id in current_revs:
if rev_id in visited:
continue
visited.add(rev_id)
rev = script_dir.get_revision(rev_id)
down = rev.down_revision
if down is None:
continue # root migration
if isinstance(down, str):
try:
script_dir.get_revision(down)
except Exception:
print(f"[migration-check] ERROR: Broken link — {rev_id} -> {down} (not found)")
sys.exit(1)
next_revs.append(down)
elif isinstance(down, tuple):
# Merge migration — check all parents exist
for parent in down:
try:
script_dir.get_revision(parent)
except Exception:
print(f"[migration-check] ERROR: Broken merge link — {rev_id} -> {parent} (not found)")
sys.exit(1)
next_revs.append(parent)
current_revs = next_revs
print(f"[migration-check] OK: Chain integrity verified — {len(visited)} migrations traversed")
# #endregion check_migration_chain_integrity
# #region check_migration_main [C:2] [TYPE Function]
# @BRIEF Entry point — runs all migration chain checks.
def main() -> None:
print("[migration-check] === Alembic Migration Chain Validation ===")
check_migration_heads()
check_migration_chain_integrity()
print("[migration-check] === All checks passed ===")
# #endregion check_migration_main
if __name__ == "__main__":
main()