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