fix: use sync engine for alembic migration operations

SQLAlchemy 2.0 async engines don't support the sync context manager
protocol needed by alembic. Create a separate sync engine (using
psycopg2) for migration operations while keeping async engine for
application queries.

- Add psycopg2-binary dependency
- Rename async connection variable to avoid mypy confusion
- Use sync engine for MigrationContext and alembic commands
This commit is contained in:
Fusion
2026-05-18 23:09:44 +02:00
parent b9684b0107
commit 1e70462c2b
2 changed files with 43 additions and 30 deletions
+1
View File
@@ -8,6 +8,7 @@ dependencies = [
"uvicorn[standard]>=0.24.0", "uvicorn[standard]>=0.24.0",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",
"asyncpg>=0.29.0", "asyncpg>=0.29.0",
"psycopg2-binary>=2.9.0",
"alembic>=1.12.0", "alembic>=1.12.0",
"pydantic>=2.5.0", "pydantic>=2.5.0",
"pydantic-settings>=2.1.0", "pydantic-settings>=2.1.0",
+42 -30
View File
@@ -4,6 +4,7 @@ import logging
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
@@ -28,6 +29,15 @@ engine = create_async_engine(
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
def _get_sync_database_url() -> str:
"""Convert async database URL to sync URL for alembic."""
sync_url = database_url
# Replace asyncpg with psycopg2 for sync operations
if "+asyncpg" in sync_url:
sync_url = sync_url.replace("+asyncpg", "+psycopg2")
return sync_url
async def init_database( async def init_database(
max_retries: int = 5, max_retries: int = 5,
retry_delay: float = 2.0, retry_delay: float = 2.0,
@@ -42,44 +52,46 @@ async def init_database(
""" """
for attempt in range(1, max_retries + 1): for attempt in range(1, max_retries + 1):
try: try:
# Test basic connectivity # Test basic connectivity (async)
from sqlalchemy import text from sqlalchemy import text
conn = await engine.connect() test_conn = await engine.connect()
try: try:
await conn.execute(text("SELECT 1")) await test_conn.execute(text("SELECT 1"))
finally: finally:
await conn.close() await test_conn.close()
logger.info("Database connection established.") logger.info("Database connection established.")
# Check if migrations are already complete # Use sync engine for alembic operations
def _check_migrations(): sync_url = _get_sync_database_url()
alembic_cfg = Config(alembic_ini_path) sync_engine = create_engine(sync_url, poolclass=NullPool)
script = command.ScriptDirectory.from_config(alembic_cfg)
with engine.connect() as conn: try:
context = MigrationContext.configure(conn) # Check if migrations are already complete
with sync_engine.connect() as sync_conn:
context = MigrationContext.configure(sync_conn)
current_rev = context.get_current_revision() current_rev = context.get_current_revision()
alembic_cfg = Config(alembic_ini_path)
script = command.ScriptDirectory.from_config(alembic_cfg)
head_rev = script.get_current_head() head_rev = script.get_current_head()
return current_rev == head_rev, current_rev, head_rev
if current_rev == head_rev:
is_current, current_rev, head_rev = await asyncio.get_event_loop().run_in_executor( logger.info("Database is already at the latest migration (%s).", head_rev)
None, _check_migrations return True
)
logger.info(
if is_current: "Current revision: %s, Head revision: %s. Running migrations...",
logger.info("Database is already at the latest migration (%s).", head_rev) current_rev,
return True head_rev,
)
logger.info("Current revision: %s, Head revision: %s. Running migrations...", current_rev, head_rev)
# Run alembic migrations
# Run alembic migrations (sync call in executor) command.upgrade(alembic_cfg, "head")
def _run_migrations(): logger.info("Database migrations completed successfully.")
alembic_cfg = Config(alembic_ini_path) return True
command.upgrade(alembic_cfg, "head") finally:
sync_engine.dispose()
await asyncio.get_event_loop().run_in_executor(None, _run_migrations)
logger.info("Database migrations completed successfully.")
return True
except Exception as exc: except Exception as exc:
error_msg = str(exc).lower() error_msg = str(exc).lower()