alembic fix
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
114
backend/src/scripts/check_migration_chain.py
Normal file
114
backend/src/scripts/check_migration_chain.py
Normal 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()
|
||||
Reference in New Issue
Block a user