Spaces:
Runtime error
Runtime error
File size: 6,421 Bytes
9cf9a2a 72be68d 9cf9a2a 7435134 72be68d 9cf9a2a 7435134 9cf9a2a 72be68d 9cf9a2a 8272a44 6429ad3 9cf9a2a 72be68d 9cf9a2a 72be68d 9cf9a2a 092315b 9cf9a2a d41abd1 9cf9a2a 72be68d 9cf9a2a 72be68d 9cf9a2a 016376a 9cf9a2a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | """
Gestion des sessions de base de données asynchrones.
Ce module configure SQLAlchemy 2.0 avec sessions asynchrones pour PostgreSQL.
Utilise le pattern AsyncSession pour toutes les opérations DB.
Usage dans routes FastAPI:
@router.get("/laws")
async def list_laws(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Law))
return result.scalars().all()
Author: JuriX Team
"""
import logging
from typing import AsyncGenerator
from fastapi import HTTPException
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
logger = logging.getLogger(__name__)
# Base class for all SQLAlchemy models
class Base(DeclarativeBase):
"""Base class pour tous les modèles SQLAlchemy."""
pass
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG if hasattr(settings, 'DEBUG') else False, # Log SQL queries in debug mode
connect_args={
"statement_cache_size": 0,
"prepared_statement_cache_size": 0,
},
# 5 + 5, et non 20 + 10. L'ancien dimensionnement demandait jusqu'a 30
# connexions asynchrones (plus 10 synchrones) pour un service qui n'en
# utilise jamais trois. Derriere un pooler externe — le mode transaction de
# Supavisor, par exemple — le multiplexage est deja fait en amont, et un
# pool local surdimensionne ne fait que consommer le quota de connexions.
pool_size=5,
max_overflow=5,
pool_pre_ping=True, # Verify connections before using
pool_recycle=3600, # Recycle connections after 1 hour
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False, # Don't expire objects after commit
autoflush=False, # Manual flush control
autocommit=False, # Manual commit control
)
# ---------------------------------------------------------------------------
# Moteur SYNCHRONE partage
# ---------------------------------------------------------------------------
# Le pipeline de traitement (app/tasks/process_law.py) et le cache
# d'embeddings tournent dans un thread d'executor, hors boucle d'evenements :
# ils ont besoin d'un moteur synchrone. Onze appels a create_engine() etaient
# dissemines dans le code, un par fonction ou par instance, aucun n'appelait
# jamais dispose() : chaque loi traitee laissait un pool de connexions ouvert
# jusqu'a l'arret du processus.
#
# Les consommateurs doivent utiliser SyncSessionLocal(), JAMAIS sync_engine
# directement : c'est ce qui permet de rebrancher toute l'application sur une
# autre base avec sessionmaker.configure(bind=...), ce dont les tests dependent.
SYNC_DATABASE_URL = settings.DATABASE_URL.replace("+asyncpg", "")
sync_engine = create_engine(
SYNC_DATABASE_URL,
pool_size=5,
max_overflow=5,
pool_pre_ping=True,
pool_recycle=3600,
future=True,
)
SyncSessionLocal = sessionmaker(
bind=sync_engine,
expire_on_commit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency pour obtenir une session DB asynchrone.
Crée une nouvelle session pour chaque requête et garantit
sa fermeture propre même en cas d'erreur.
Yields:
AsyncSession: Session de base de données asynchrone
Example:
>>> @router.get("/laws")
>>> async def list_laws(db: AsyncSession = Depends(get_db)):
... result = await db.execute(select(Law))
... return result.scalars().all()
"""
async with AsyncSessionLocal() as session:
try:
logger.debug("📦 Nouvelle session DB créée")
yield session
except HTTPException:
# Une HTTPException n'est pas une erreur de base : c'est une
# reponse deliberee de la route. La journaliser en « Erreur session
# DB » avec sa trace complete noyait les vraies erreurs SQL sous des
# 404 et des 429 parfaitement normaux. On annule quand meme la
# transaction, sans mentir sur la cause.
await session.rollback()
raise
except Exception as e:
logger.error(f"❌ Erreur session DB: {e}")
await session.rollback()
raise
finally:
await session.close()
logger.debug("🗑️ Session DB fermée")
# init_db() et drop_db() ont ete retirees : elles appelaient
# Base.metadata.create_all/drop_all, et n'etaient appelees par personne. Le
# schema appartient a Alembic — deux sources de verite pour une meme structure
# finissent toujours par diverger.
async def close_db() -> None:
"""
Ferme le moteur de base de données.
Ferme toutes les connexions actives et libère les ressources.
Doit être appelé lors de l'arrêt de l'application.
Example:
>>> # Dans le shutdown event de FastAPI
>>> @app.on_event("shutdown")
>>> async def shutdown():
... await close_db()
"""
await engine.dispose()
close_sync_db()
logger.info("✅ Moteur de base de données fermé")
def close_sync_db() -> None:
"""Ferme le moteur synchrone partage. Appele par close_db()."""
sync_engine.dispose()
logger.info("✅ Moteur synchrone fermé")
async def health_check_db() -> bool:
"""
Vérifie la santé de la connexion à la base de données.
Returns:
bool: True si la connexion fonctionne, False sinon
Example:
>>> is_healthy = await health_check_db()
>>> if not is_healthy:
... logger.error("DB connection failed")
"""
try:
async with AsyncSessionLocal() as session:
# text() obligatoire : SQLAlchemy 2.0 refuse une chaine brute
# (ObjectNotExecutableError). L'exception etait avalee par le except
# ci-dessous, donc health_check_db renvoyait TOUJOURS False et
# GET /api/v1/admin/system declarait la base en panne en permanence.
await session.execute(text("SELECT 1"))
logger.debug("✅ DB health check OK")
return True
except Exception as e:
logger.error(f"❌ DB health check failed: {e}")
return False
|