File size: 7,043 Bytes
dc1b199
 
a1e2ff8
dc1b199
faa8fb3
dc1b199
 
 
 
 
 
 
 
a1e2ff8
 
 
 
dc1b199
 
 
a1e2ff8
 
dc1b199
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
3c31a2a
 
 
 
dc1b199
 
 
 
 
732b14f
faa8fb3
3c31a2a
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1e2ff8
 
 
 
 
 
 
dc1b199
 
 
 
 
 
 
 
a1e2ff8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
b76f199
732b14f
 
 
 
 
b76f199
732b14f
 
b76f199
732b14f
 
b76f199
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
b76f199
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
193
194
195
196
197
198
199
200
201
202
203
204
205
"""Async SQLAlchemy engine, session factory, and database initialisation."""

import logging
from collections.abc import AsyncGenerator
from typing import Any

from sqlalchemy.ext.asyncio import (
    AsyncEngine,
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
# starlette.exceptions.HTTPException is the base class of fastapi.HTTPException;
# catching the Starlette one filters out both routine 4xx flows that propagate
# through yield-based dependencies in FastAPI >= 0.106.
from starlette.exceptions import HTTPException as StarletteHTTPException

from app.config import settings

logger = logging.getLogger(__name__)


class Base(DeclarativeBase):
    """Declarative base shared by all ORM models."""


_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None


def is_sqlite_database() -> bool:
    """True when the configured async URL targets SQLite."""
    return "sqlite" in (settings.database_url or "").lower()


def parallel_section_writes_safe() -> bool:
    """Whether concurrent section DB writers are safe for the active backend."""
    if is_sqlite_database() and not settings.allow_sqlite_parallel_sections:
        return False
    return True


def multi_section_parallel_enabled() -> bool:
    """Parallel multi-section jobs (in-process or Temporal) when async pipeline is on."""
    return bool(settings.enable_async_pipeline and parallel_section_writes_safe())


def get_engine() -> AsyncEngine:
    """Return (or lazily create) the async SQLAlchemy engine.

    SQLite β€” uses ``check_same_thread=False`` (required for async).
    PostgreSQL / other β€” enables ``pool_pre_ping`` so stale connections are
    transparently recycled rather than causing 500 errors under load.

    Returns:
        The singleton ``AsyncEngine`` instance.
    """
    global _engine
    if _engine is None:
        is_sqlite = is_sqlite_database()
        kwargs: dict[str, Any] = {
            "echo": settings.dev_mode,
        }
        if is_sqlite:
            kwargs["connect_args"] = {"check_same_thread": False}
        else:
            # For PostgreSQL / MySQL: recycle stale connections and limit pool
            # size so we don't exhaust DB connection slots under high concurrency.
            kwargs["pool_pre_ping"] = True
            kwargs["pool_size"] = 10
            kwargs["max_overflow"] = 20
            kwargs["pool_recycle"] = 1800  # recycle connections every 30 min
        _engine = create_async_engine(settings.database_url, **kwargs)
    return _engine


def get_session_factory() -> async_sessionmaker[AsyncSession]:
    """Return (or lazily create) the async session factory.

    Returns:
        An ``async_sessionmaker`` bound to the singleton engine.
    """
    global _session_factory
    if _session_factory is None:
        _session_factory = async_sessionmaker(
            get_engine(),
            expire_on_commit=False,
        )
    return _session_factory


async def get_db() -> AsyncGenerator[AsyncSession, None]:
    """FastAPI dependency that yields a transactional database session.

    Commits on success, rolls back on any exception.

    Note on logging: from FastAPI 0.106 onwards, exceptions raised in path
    operations β€” including ``HTTPException`` for routine 404 / 409 / 413 / 422
    responses β€” propagate to yield-based dependencies. We therefore handle
    ``HTTPException`` separately and roll back quietly; logging it with
    ``logger.exception`` would emit an ERROR-level stack trace for every
    routine 4xx response and bury genuine database errors in the noise.

    Yields:
        ``AsyncSession`` scoped to a single request.
    """
    factory = get_session_factory()
    async with factory() as session:
        try:
            yield session
            await session.commit()
        except StarletteHTTPException:
            # Routine HTTP responses (4xx) β€” not a database failure. Roll back
            # any open transaction so the session is clean, but stay quiet so
            # the logs don't drown in stack traces for every NotFound / Conflict.
            try:
                await session.rollback()
            except Exception:
                logger.exception("Rollback failed while propagating HTTPException")
            raise
        except Exception as _exc:
            logger.exception("DB session error β€” rolling back: %s", _exc)
            try:
                await session.rollback()
            except Exception as _rb_exc:
                logger.error("Rollback itself failed: %s", _rb_exc)
            raise


def _sqlite_add_column_if_missing(connection: object, table: str, column: str, ddl_suffix: str) -> None:
    """Best-effort SQLite ALTER for deployments that created tables before new columns existed."""
    from sqlalchemy import inspect, text

    insp = inspect(connection)
    if not insp.has_table(table):
        return
    cols = {c["name"] for c in insp.get_columns(table)}
    if column in cols:
        return
    connection.execute(text(f"ALTER TABLE {table} ADD COLUMN {ddl_suffix}"))


def _postgres_add_column_if_missing(
    connection: object,
    table: str,
    column: str,
    ddl_suffix: str,
) -> None:
    """Best-effort PostgreSQL ALTER for nullable columns added after first deploy."""
    from sqlalchemy import inspect, text

    insp = inspect(connection)
    if not insp.has_table(table):
        return
    cols = {c["name"] for c in insp.get_columns(table)}
    if column in cols:
        return
    connection.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {ddl_suffix}"))


async def migrate_schema() -> None:
    """Apply lightweight schema upgrades when models gain nullable columns."""
    engine = get_engine()

    def _upgrade(sync_conn: object) -> None:
        if is_sqlite_database():
            _sqlite_add_column_if_missing(
                sync_conn, "documents", "survey_level", "survey_level INTEGER"
            )
            _sqlite_add_column_if_missing(
                sync_conn, "reports", "survey_level", "survey_level INTEGER"
            )
            _sqlite_add_column_if_missing(
                sync_conn,
                "reports",
                "generation_started_at",
                "generation_started_at DATETIME",
            )
        else:
            _postgres_add_column_if_missing(
                sync_conn,
                "reports",
                "generation_started_at",
                "generation_started_at TIMESTAMP WITH TIME ZONE",
            )

    async with engine.begin() as conn:
        await conn.run_sync(_upgrade)


async def init_db() -> None:
    """Create all tables if they do not already exist (idempotent).

    Example::

        await init_db()
    """
    from app.db import models as _models  # noqa: F401 β€” registers ORM metadata

    engine = get_engine()
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    await migrate_schema()