File size: 14,149 Bytes
dc1b199
 
a1e2ff8
dc1b199
faa8fb3
dc1b199
 
 
 
 
 
 
 
a1e2ff8
 
 
 
dc1b199
 
 
a1e2ff8
 
dc1b199
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
865bc90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
3c31a2a
 
 
 
dc1b199
 
 
 
 
732b14f
faa8fb3
3c31a2a
 
 
 
 
 
 
 
 
 
 
 
c893230
 
dc1b199
 
 
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1e2ff8
 
 
 
 
 
 
dc1b199
 
 
 
 
 
 
 
a1e2ff8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
b76f199
732b14f
 
 
 
 
b76f199
732b14f
 
b76f199
865bc90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
b76f199
 
 
732b14f
865bc90
732b14f
 
 
 
 
 
 
 
 
 
 
 
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aad7814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
865bc90
732b14f
 
 
 
 
 
c893230
 
 
 
 
 
 
 
 
 
 
 
aad7814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
"""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).

    Enabled by either the async pipeline OR the dedicated
    ``enable_parallel_section_generation`` flag, and only when concurrent
    section writes are safe for the active DB backend (SQLite requires
    ``allow_sqlite_parallel_sections``). This only governs the Temporal
    workflow request's ``parallel_sections`` field; the default (non-Temporal)
    job path is unaffected.
    """
    if not parallel_section_writes_safe():
        return False
    return bool(
        settings.enable_async_pipeline
        or settings.enable_parallel_section_generation
    )


def effective_section_concurrency() -> int:
    """Bounded section concurrency for a multi-section job.

    Decoupled from ``enable_async_pipeline``: section generation is dominated by
    I/O-bound LLM calls, so concurrency is safe and worthwhile regardless of the
    async-pipeline flag. Falls back to 1 (sequential) when concurrent section
    writes are not safe for the active DB backend.
    """
    requested = max(1, int(getattr(settings, "section_generation_concurrency", 1)))
    if requested <= 1:
        return 1
    if not parallel_section_writes_safe():
        return 1
    return requested


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)
        if is_sqlite:
            _enable_sqlite_concurrency(_engine)
    return _engine


def _enable_sqlite_concurrency(engine: AsyncEngine) -> None:
    """Set WAL + busy_timeout on every SQLite connection.

    WAL lets readers and a single writer proceed concurrently; busy_timeout makes
    a blocked writer wait (instead of raising 'database is locked') when parallel
    section jobs commit at the same time. ``synchronous=NORMAL`` is the standard
    durable-enough pairing with WAL.
    """
    from sqlalchemy import event

    @event.listens_for(engine.sync_engine, "connect")
    def _set_sqlite_pragmas(dbapi_conn: Any, _record: Any) -> None:  # noqa: ANN401
        cursor = dbapi_conn.cursor()
        try:
            cursor.execute("PRAGMA journal_mode=WAL")
            cursor.execute("PRAGMA busy_timeout=10000")
            cursor.execute("PRAGMA synchronous=NORMAL")
        finally:
            cursor.close()


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}"))


def _sqlite_drop_not_null(connection: object, table: str, column: str) -> None:
    """Relax a ``NOT NULL`` constraint on one SQLite column via a table rebuild.

    SQLite cannot ``ALTER COLUMN``; the standard recipe is to recreate the table
    with the relaxed definition and copy rows. Idempotent: no-op when the column
    is already nullable or absent. FK declarations are intentionally dropped on
    rebuild (we want lenient deletes), and the ``ix_<table>_tenant_id`` helper
    index is recreated.
    """
    from sqlalchemy import text

    info = connection.execute(text(f"PRAGMA table_info({table})")).fetchall()  # type: ignore[attr-defined]
    if not info:
        return
    target = next((row for row in info if row[1] == column), None)
    if target is None or int(target[3]) == 0:
        # column missing, or already nullable
        return

    col_defs: list[str] = []
    for _cid, name, ctype, notnull, dflt, pk in info:
        piece = f'"{name}" {ctype or ""}'.rstrip()
        if int(pk):
            piece += " PRIMARY KEY"
        if int(notnull) and name != column:
            piece += " NOT NULL"
        if dflt is not None:
            piece += f" DEFAULT {dflt}"
        col_defs.append(piece)
    col_names = ", ".join(f'"{row[1]}"' for row in info)
    tmp = f"{table}__migrate_tmp"

    connection.execute(text("PRAGMA foreign_keys=OFF"))  # type: ignore[attr-defined]
    connection.execute(text(f'DROP TABLE IF EXISTS "{tmp}"'))  # type: ignore[attr-defined]
    connection.execute(text(f'CREATE TABLE "{tmp}" ({", ".join(col_defs)})'))  # type: ignore[attr-defined]
    connection.execute(  # type: ignore[attr-defined]
        text(f'INSERT INTO "{tmp}" ({col_names}) SELECT {col_names} FROM "{table}"')
    )
    connection.execute(text(f'DROP TABLE "{table}"'))  # type: ignore[attr-defined]
    connection.execute(text(f'ALTER TABLE "{tmp}" RENAME TO "{table}"'))  # type: ignore[attr-defined]
    connection.execute(  # type: ignore[attr-defined]
        text(f'CREATE INDEX IF NOT EXISTS "ix_{table}_tenant_id" ON "{table}" (tenant_id)')
    )


def _postgres_drop_not_null(connection: object, table: str, column: str) -> None:
    """Relax a ``NOT NULL`` constraint on PostgreSQL (idempotent)."""
    from sqlalchemy import inspect, text

    insp = inspect(connection)
    if not insp.has_table(table):
        return
    col = next((c for c in insp.get_columns(table) if c["name"] == column), None)
    if col is None or col.get("nullable", True):
        return
    connection.execute(text(f'ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL'))


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_drop_not_null(sync_conn, "reports", "document_id")
            _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",
            )
            _sqlite_add_column_if_missing(
                sync_conn,
                "reports",
                "generation_section_total",
                "generation_section_total INTEGER",
            )
            # SQLite stores StrEnum as VARCHAR; defaulting at ALTER time is
            # how legacy rows get the new column populated without a backfill
            # script.
            _sqlite_add_column_if_missing(
                sync_conn,
                "documents",
                "document_purpose",
                "document_purpose VARCHAR(32) NOT NULL DEFAULT 'report_source'",
            )
            _sqlite_add_column_if_missing(
                sync_conn,
                "report_section_photos",
                "selected_for_ai",
                "selected_for_ai BOOLEAN NOT NULL DEFAULT 0",
            )
            _sqlite_add_column_if_missing(
                sync_conn,
                "documents",
                "redaction_strategy",
                "redaction_strategy VARCHAR(32) NOT NULL DEFAULT 'ai_hybrid'",
            )
            _sqlite_add_column_if_missing(
                sync_conn,
                "documents",
                "redaction_context_json",
                "redaction_context_json TEXT",
            )
        else:
            _postgres_drop_not_null(sync_conn, "reports", "document_id")
            _postgres_add_column_if_missing(
                sync_conn,
                "reports",
                "generation_started_at",
                "generation_started_at TIMESTAMP WITH TIME ZONE",
            )
            _postgres_add_column_if_missing(
                sync_conn,
                "reports",
                "generation_section_total",
                "generation_section_total INTEGER",
            )
            _postgres_add_column_if_missing(
                sync_conn,
                "documents",
                "document_purpose",
                "document_purpose VARCHAR(32) NOT NULL DEFAULT 'report_source'",
            )
            _postgres_add_column_if_missing(
                sync_conn,
                "report_section_photos",
                "selected_for_ai",
                "selected_for_ai BOOLEAN NOT NULL DEFAULT false",
            )
            _postgres_add_column_if_missing(
                sync_conn,
                "documents",
                "redaction_strategy",
                "redaction_strategy VARCHAR(32) NOT NULL DEFAULT 'ai_hybrid'",
            )
            _postgres_add_column_if_missing(
                sync_conn,
                "documents",
                "redaction_context_json",
                "redaction_context_json TEXT",
            )

    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()