Spaces:
Sleeping
Sleeping
| """ | |
| Database Session Management | |
| SQLAlchemy engine and session configuration with connection pooling. | |
| Two engines exist here on purpose: | |
| async_engine / AsyncSessionLocal | |
| What request handlers use. Every endpoint is `async def`, so a | |
| synchronous DB call inside one blocks the event loop for its whole | |
| duration — every other request served by that worker waits behind it. | |
| engine / SessionLocal (synchronous) | |
| Retained for the things that genuinely are not async: Alembic, the | |
| migration runner, and startup schema checks. Also still used by the | |
| handlers that have not been converted yet; both paths work against the | |
| same database while the conversion proceeds module by module. | |
| """ | |
| import logging | |
| from contextlib import asynccontextmanager, contextmanager | |
| from typing import AsyncGenerator, Generator | |
| from sqlalchemy import create_engine, event, text | |
| from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine | |
| from sqlalchemy.orm import Session, sessionmaker | |
| from sqlalchemy.pool import NullPool, QueuePool | |
| from app.config import settings | |
| # Use the single canonical Base so all models share the same metadata | |
| from app.models.base import Base # noqa: F401 - re-exported for convenience | |
| logger = logging.getLogger(__name__) | |
| # Configure engine with connection pooling | |
| engine_args = { | |
| "pool_size": settings.DB_POOL_SIZE, | |
| "max_overflow": settings.DB_MAX_OVERFLOW, | |
| "pool_pre_ping": True, # Verify connections before use | |
| "pool_recycle": 3600, # Recycle connections after 1 hour | |
| "echo": False, # Suppress excessive SQL query logging | |
| } | |
| # Add SSL config if certificate path provided | |
| connect_args = {} | |
| if settings.SSL_CERT_PATH: | |
| connect_args["sslmode"] = "require" | |
| connect_args["sslrootcert"] = settings.SSL_CERT_PATH | |
| engine = create_engine( | |
| settings.DATABASE_URL, poolclass=QueuePool, connect_args=connect_args, **engine_args | |
| ) | |
| # Session factory | |
| SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) | |
| # ── Async engine ─────────────────────────────────────────────────────────────── | |
| def _async_url(url: str) -> str: | |
| """ | |
| Translate a sync DATABASE_URL to its async driver equivalent. | |
| Deployments set DATABASE_URL with a sync driver (Supabase hands you | |
| `postgresql://`, and this project's compose files use | |
| `postgresql+psycopg2://`). Rather than require every environment to be | |
| edited — including the HuggingFace Space, where a mistake means a failed | |
| boot — the async driver is substituted here. | |
| psycopg3 is used rather than asyncpg because `psycopg[binary,pool]` is | |
| already a dependency and psycopg3 speaks both sync and async, so the two | |
| engines share one driver. | |
| """ | |
| if url.startswith("postgresql+psycopg2://"): | |
| return url.replace("postgresql+psycopg2://", "postgresql+psycopg://", 1) | |
| if url.startswith("postgresql://"): | |
| return url.replace("postgresql://", "postgresql+psycopg://", 1) | |
| if url.startswith("postgres://"): # legacy Heroku-style | |
| return url.replace("postgres://", "postgresql+psycopg://", 1) | |
| if url.startswith("sqlite://") and "+aiosqlite" not in url: | |
| return url.replace("sqlite://", "sqlite+aiosqlite://", 1) | |
| return url | |
| def _is_transaction_pooler(url: str) -> bool: | |
| """ | |
| Detect Supabase's transaction pooler (port 6543). | |
| PgBouncer in transaction mode multiplexes one server connection across | |
| clients, so server-side prepared statements leak between sessions and | |
| error with "prepared statement already exists". psycopg3 prepares | |
| automatically after a few executions, so it has to be told not to. | |
| """ | |
| return ":6543" in url or "pgbouncer=true" in url.lower() | |
| ASYNC_DATABASE_URL = _async_url(settings.DATABASE_URL) | |
| _async_connect_args = dict(connect_args) | |
| _async_engine_args = dict(engine_args) | |
| if ASYNC_DATABASE_URL.startswith("postgresql+psycopg"): | |
| if _is_transaction_pooler(ASYNC_DATABASE_URL): | |
| # Disable prepared statements, and do not hold a client-side pool on | |
| # top of the server-side one — that is how you exhaust a pooler. | |
| _async_connect_args["prepare_threshold"] = None | |
| _async_engine_args = {"poolclass": NullPool, "echo": False} | |
| logger.info("Transaction pooler detected: prepared statements disabled") | |
| elif ASYNC_DATABASE_URL.startswith("sqlite"): | |
| # SQLite (tests) supports none of the pool tuning above. | |
| _async_engine_args = {"echo": False} | |
| _async_connect_args = {} | |
| async_engine = create_async_engine( | |
| ASYNC_DATABASE_URL, | |
| connect_args=_async_connect_args, | |
| **_async_engine_args, | |
| ) | |
| AsyncSessionLocal = async_sessionmaker( | |
| bind=async_engine, | |
| class_=AsyncSession, | |
| autocommit=False, | |
| autoflush=False, | |
| # Attributes stay usable after commit(); without this every commit | |
| # invalidates loaded objects and the next attribute read emits a lazy | |
| # refresh, which raises MissingGreenlet outside a greenlet context. | |
| expire_on_commit=False, | |
| ) | |
| # Connection event listeners for debugging | |
| def on_connect(dbapi_conn, connection_record): | |
| logger.debug("Database connection established") | |
| def on_checkout(dbapi_conn, connection_record, connection_proxy): | |
| logger.debug("Database connection checked out from pool") | |
| def get_db() -> Generator[Session, None, None]: | |
| """ | |
| Dependency for FastAPI endpoints. | |
| Yields a database session and ensures cleanup. | |
| Usage: | |
| @app.get("/items") | |
| def get_items(db: Session = Depends(get_db)): | |
| ... | |
| """ | |
| db = SessionLocal() | |
| try: | |
| yield db | |
| finally: | |
| db.close() | |
| async def get_async_db() -> AsyncGenerator[AsyncSession, None]: | |
| """ | |
| Dependency for async FastAPI endpoints. | |
| Usage: | |
| @router.get("/items") | |
| async def get_items(db: AsyncSession = Depends(get_async_db)): | |
| result = await db.execute(select(Item)) | |
| return result.scalars().all() | |
| The session is rolled back on an unhandled exception rather than left for | |
| the pool to reset, so a failed request cannot hand a dirty transaction to | |
| the next one that checks the connection out. | |
| """ | |
| async with AsyncSessionLocal() as session: | |
| try: | |
| yield session | |
| except Exception: | |
| await session.rollback() | |
| raise | |
| async def get_async_db_context() -> AsyncGenerator[AsyncSession, None]: | |
| """ | |
| Async session for use outside FastAPI — background workers and scripts. | |
| Commits on clean exit, rolls back on exception. | |
| """ | |
| async with AsyncSessionLocal() as session: | |
| try: | |
| yield session | |
| await session.commit() | |
| except Exception: | |
| await session.rollback() | |
| raise | |
| def get_db_context() -> Generator[Session, None, None]: | |
| """ | |
| Context manager for database sessions outside of FastAPI. | |
| Useful for background workers and scripts. | |
| Usage: | |
| with get_db_context() as db: | |
| db.query(...) | |
| """ | |
| db = SessionLocal() | |
| try: | |
| yield db | |
| db.commit() | |
| except Exception: | |
| db.rollback() | |
| raise | |
| finally: | |
| db.close() | |
| # Indexes that Base.metadata.create_all() cannot express: pgvector's HNSW index | |
| # and the pg_trgm GIN indexes. They live in Alembic migrations 001/002, but the | |
| # migration chain has no baseline revision (001 ALTERs tables it assumes already | |
| # exist), so `alembic upgrade head` cannot bootstrap a fresh database and in | |
| # practice the migrations never run. | |
| # | |
| # Until a proper baseline migration exists, these are applied here so a fresh | |
| # deployment is not left doing sequential scans over every embedding — which is | |
| # what "sub-5ms ANN search" silently degrades to without idx_embeddings_hnsw. | |
| # | |
| # All statements are IF NOT EXISTS, so this is safe to run on every startup and | |
| # against a database that already has them. | |
| _REQUIRED_INDEXES = ( | |
| ( | |
| "idx_embeddings_hnsw", | |
| """ | |
| CREATE INDEX IF NOT EXISTS idx_embeddings_hnsw | |
| ON document_embeddings | |
| USING hnsw (embedding vector_cosine_ops) | |
| WITH (m = 16, ef_construction = 200) | |
| """, | |
| ), | |
| ( | |
| "idx_embeddings_doc_chunk", | |
| """ | |
| CREATE UNIQUE INDEX IF NOT EXISTS idx_embeddings_doc_chunk | |
| ON document_embeddings (document_id, chunk_index) | |
| """, | |
| ), | |
| # Lexical retrieval. Deliberately NOT a trigram index on chunk_text: that | |
| # is what migration 003 removed, because whole-string trigram similarity | |
| # between a long chunk and a short question never clears pg_trgm's | |
| # threshold. See alembic/versions/003_fulltext_search.py. | |
| ( | |
| "chunk_tsv column", | |
| """ | |
| ALTER TABLE document_embeddings | |
| ADD COLUMN IF NOT EXISTS chunk_tsv tsvector | |
| GENERATED ALWAYS AS (to_tsvector('english', chunk_text)) STORED | |
| """, | |
| ), | |
| ( | |
| "idx_embeddings_chunk_tsv", | |
| """ | |
| CREATE INDEX IF NOT EXISTS idx_embeddings_chunk_tsv | |
| ON document_embeddings USING gin (chunk_tsv) | |
| """, | |
| ), | |
| ( | |
| "idx_documents_title_trgm", | |
| """ | |
| CREATE INDEX IF NOT EXISTS idx_documents_title_trgm | |
| ON documents USING gin (title gin_trgm_ops) | |
| """, | |
| ), | |
| ( | |
| "idx_documents_filename_trgm", | |
| """ | |
| CREATE INDEX IF NOT EXISTS idx_documents_filename_trgm | |
| ON documents USING gin (file_name gin_trgm_ops) | |
| """, | |
| ), | |
| ) | |
| def init_db(): | |
| """Initialize database tables using the canonical Base from models.base""" | |
| # Import all models so they register their tables with Base.metadata | |
| from app.models import audit, chat, document, prompt, user # noqa: F401 | |
| from app.models.base import Base as ModelBase | |
| is_postgres = engine.dialect.name == "postgresql" | |
| if is_postgres: | |
| # Extensions must exist before tables with VECTOR columns are created. | |
| with engine.connect() as conn: | |
| conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) | |
| conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) | |
| conn.commit() | |
| logger.info("pgvector and pg_trgm extensions ensured") | |
| ModelBase.metadata.create_all(bind=engine) | |
| logger.info("Database tables created successfully") | |
| if is_postgres: | |
| ensure_indexes() | |
| def ensure_indexes(): | |
| """ | |
| Create the vector and trigram indexes if they are missing. | |
| Building the HNSW index on an already-large table takes time and holds a | |
| lock on document_embeddings. On a fresh or small database this is | |
| negligible; if you are adding it to a table with millions of rows, create | |
| it out of band with CREATE INDEX CONCURRENTLY instead of relying on this. | |
| """ | |
| for name, ddl in _REQUIRED_INDEXES: | |
| try: | |
| with engine.connect() as conn: | |
| conn.execute(text(ddl)) | |
| conn.commit() | |
| logger.debug(f"Index ensured: {name}") | |
| except Exception as e: | |
| # A missing index degrades performance; it must not stop the app. | |
| logger.warning(f"Could not create index {name}: {e}") | |
| def check_db_connection() -> bool: | |
| """Check if database is reachable""" | |
| try: | |
| with engine.connect() as conn: | |
| conn.execute(text("SELECT 1")) | |
| return True | |
| except Exception as e: | |
| logger.error(f"Database connection failed: {e}") | |
| return False | |