255 lines
9.8 KiB
Python
255 lines
9.8 KiB
Python
import contextvars
|
|
import logging
|
|
from typing import Any
|
|
|
|
from sqlalchemy import MetaData, event, text
|
|
from sqlalchemy.ext.asyncio import (
|
|
AsyncSession,
|
|
async_sessionmaker,
|
|
create_async_engine,
|
|
)
|
|
from sqlalchemy.orm import declarative_base
|
|
from sqlalchemy.pool import NullPool, QueuePool
|
|
|
|
from src.config import settings
|
|
from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
connect_args = {
|
|
"prepare_threshold": None,
|
|
# Bound a single connection attempt so it fails fast instead of hanging when
|
|
# the server/pooler is unreachable or stalled (psycopg, seconds).
|
|
"connect_timeout": settings.DB.CONNECT_TIMEOUT_SECONDS,
|
|
}
|
|
|
|
# Context variable to store request context
|
|
request_context: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
"request_context", default=None
|
|
)
|
|
|
|
engine_kwargs = {}
|
|
|
|
if settings.DB.POOL_CLASS == "null":
|
|
engine_kwargs["poolclass"] = NullPool
|
|
else:
|
|
# Only add pool-related kwargs for pooled connections
|
|
engine_kwargs.update( # pyright: ignore
|
|
{
|
|
"pool_pre_ping": settings.DB.POOL_PRE_PING,
|
|
"pool_size": settings.DB.POOL_SIZE,
|
|
"max_overflow": settings.DB.MAX_OVERFLOW,
|
|
"pool_timeout": settings.DB.POOL_TIMEOUT,
|
|
"pool_recycle": settings.DB.POOL_RECYCLE,
|
|
"pool_use_lifo": settings.DB.POOL_USE_LIFO,
|
|
}
|
|
)
|
|
|
|
engine = create_async_engine(
|
|
settings.DB.CONNECTION_URI,
|
|
connect_args=connect_args,
|
|
echo=settings.DB.SQL_DEBUG,
|
|
**engine_kwargs,
|
|
)
|
|
|
|
# A vanilla AsyncSession is lazy: it checks out a pooled connection on the first
|
|
# DB-touching call (not at construction) and couples the checkout to the
|
|
# statement, so a handler doing non-DB work (embedding/file/LLM) before its
|
|
# first query does not pin a connection across it. Connection acquisition is a
|
|
# single attempt with no retry — callers handle a saturated/unreachable DB (the
|
|
# API surfaces the error; the deriver backs off and retries on a later poll).
|
|
SessionLocal = async_sessionmaker(
|
|
autocommit=False,
|
|
autoflush=False,
|
|
expire_on_commit=False,
|
|
bind=engine,
|
|
class_=AsyncSession,
|
|
)
|
|
|
|
# Read-only engine: shares `engine`'s pool, but checks connections out in DBAPI
|
|
# AUTOCOMMIT mode, so psycopg emits NO BEGIN — a SELECT never autobegins a
|
|
# transaction. The backend therefore returns to state 'idle' (not 'idle in
|
|
# transaction') the moment a statement completes.
|
|
read_engine = engine.execution_options(isolation_level="AUTOCOMMIT")
|
|
|
|
# Sessions for SELECT-only work (same lazy-checkout semantics as SessionLocal).
|
|
# MUST NOT be used for writes: with no enclosing transaction, begin_nested()
|
|
# savepoints (see the crud get-or-create paths) break, and every flush would
|
|
# commit immediately. Use SessionLocal for anything that mutates.
|
|
ReadSessionLocal = async_sessionmaker(
|
|
autocommit=False,
|
|
autoflush=False,
|
|
expire_on_commit=False,
|
|
bind=read_engine,
|
|
class_=AsyncSession,
|
|
)
|
|
|
|
|
|
def _set_application_name_on_checkout(
|
|
dbapi_connection: Any, _connection_record: Any, _connection_proxy: Any
|
|
) -> None:
|
|
"""Tag each checked-out connection with the current request context.
|
|
|
|
Registered only when ``DB.TRACING`` is on. Fires on every pool checkout (so a
|
|
reused pooled connection is re-tagged for the new caller), reading the
|
|
per-task ``request_context`` the request/task scope has already set.
|
|
Best-effort: a failure here must never break the checkout.
|
|
|
|
Runs in autocommit so it never leaves the connection 'idle in transaction'
|
|
at checkout: this hook fires BEFORE the dialect applies execution-option
|
|
isolation levels, and psycopg refuses to switch a connection into AUTOCOMMIT
|
|
(which the read engine does) while a transaction opened by this statement is
|
|
still in progress. set_config(..., is_local=false) is session-scoped, so it
|
|
persists past the autocommit boundary.
|
|
"""
|
|
context = request_context.get() or "unknown"
|
|
try:
|
|
previous_autocommit = dbapi_connection.autocommit
|
|
if not previous_autocommit:
|
|
dbapi_connection.autocommit = True
|
|
try:
|
|
cursor = dbapi_connection.cursor()
|
|
try:
|
|
cursor.execute(
|
|
"SELECT set_config('application_name', %s, false)", (context,)
|
|
)
|
|
finally:
|
|
cursor.close()
|
|
finally:
|
|
if not previous_autocommit:
|
|
dbapi_connection.autocommit = False
|
|
except Exception:
|
|
logger.debug("setting application_name on checkout failed", exc_info=True)
|
|
|
|
|
|
if settings.DB.TRACING:
|
|
event.listen(engine.sync_engine, "checkout", _set_application_name_on_checkout)
|
|
|
|
|
|
def get_pool_stats() -> dict[str, int]:
|
|
"""Return live connection-pool stats for this process.
|
|
|
|
``engine.pool`` is the AsyncEngine's pool (the same object as
|
|
``engine.sync_engine.pool``); its stat methods are synchronous counter
|
|
reads with no I/O, so they are safe to call without ``await``. Returns
|
|
zeros for pools that do not track connections (e.g. ``NullPool``).
|
|
"""
|
|
zeros = {"checked_out": 0, "checked_in": 0, "size": 0, "overflow": 0}
|
|
pool = engine.pool
|
|
# Only QueuePool (and its AsyncAdaptedQueuePool subclass) tracks connection
|
|
# counts; NullPool and others have no meaningful stats.
|
|
if not isinstance(pool, QueuePool):
|
|
return zeros
|
|
try:
|
|
# overflow() is negative until the base pool fills (it starts at
|
|
# -pool_size); clamp to the count of overflow connections actually open.
|
|
return {
|
|
"checked_out": pool.checkedout(),
|
|
"checked_in": pool.checkedin(),
|
|
"size": pool.size(),
|
|
"overflow": max(0, pool.overflow()),
|
|
}
|
|
except Exception:
|
|
return zeros
|
|
|
|
|
|
class DBQueryInflightTracker:
|
|
"""Tracks statements executing on the wire via SQLAlchemy cursor events.
|
|
|
|
Drift-proof: marks ``Connection.info`` when a statement starts and clears it
|
|
on completion OR error, so the gauge can't leak upward (an errored statement
|
|
skips ``after_cursor_execute``) or go negative (a connect-time error has no
|
|
matching start). Bound to a pre-resolved labeled gauge child so the
|
|
per-statement hot path does no label resolution.
|
|
"""
|
|
|
|
# Marker on Connection.info recording that we incremented for the current
|
|
# statement, so we decrement exactly once on completion or error.
|
|
INFLIGHT_KEY: str = "_honcho_inflight"
|
|
|
|
def __init__(self, gauge_child: Any) -> None:
|
|
self._child: Any = gauge_child
|
|
|
|
def on_before(self, conn: Any, *_: Any) -> None:
|
|
try:
|
|
conn.info[self.INFLIGHT_KEY] = True
|
|
self._child.inc()
|
|
except Exception:
|
|
logger.debug("in-flight gauge inc failed", exc_info=True)
|
|
|
|
def on_after(self, conn: Any, *_: Any) -> None:
|
|
try:
|
|
if conn.info.pop(self.INFLIGHT_KEY, False):
|
|
self._child.dec()
|
|
except Exception:
|
|
logger.debug("in-flight gauge dec failed", exc_info=True)
|
|
|
|
def on_error(self, exception_context: Any) -> None:
|
|
try:
|
|
conn = exception_context.connection
|
|
if conn is not None and conn.info.pop(self.INFLIGHT_KEY, False):
|
|
self._child.dec()
|
|
except Exception:
|
|
logger.debug("in-flight gauge error-path dec failed", exc_info=True)
|
|
|
|
|
|
# Process-wide tracker, created at registration (None until then / if metrics off).
|
|
_inflight_tracker: DBQueryInflightTracker | None = None
|
|
|
|
|
|
_db_query_instrumentation_registered = False
|
|
|
|
|
|
def register_db_query_instrumentation(instance_type: str) -> None:
|
|
"""Attach per-statement in-flight tracking to the engine (no-op if off).
|
|
|
|
Gated on METRICS.ENABLED so there is zero overhead — not even attached event
|
|
listeners — when metrics are disabled. Idempotent: repeated calls (e.g. a
|
|
re-run lifespan or test startup) won't attach duplicate listeners, which
|
|
would double-count in-flight statements.
|
|
"""
|
|
global _inflight_tracker, _db_query_instrumentation_registered
|
|
if not settings.METRICS.ENABLED or _db_query_instrumentation_registered:
|
|
return
|
|
child = db_queries_in_flight_gauge.labels(instance_type=instance_type)
|
|
_inflight_tracker = DBQueryInflightTracker(child)
|
|
sync_engine = engine.sync_engine
|
|
event.listen(sync_engine, "before_cursor_execute", _inflight_tracker.on_before)
|
|
event.listen(sync_engine, "after_cursor_execute", _inflight_tracker.on_after)
|
|
event.listen(sync_engine, "handle_error", _inflight_tracker.on_error)
|
|
_db_query_instrumentation_registered = True
|
|
|
|
|
|
# Define your naming convention
|
|
convention = {
|
|
"ix": "ix_%(table_name)s_%(column_0_N_name)s", # Index - supports multi-column
|
|
"uq": "uq_%(table_name)s_%(column_0_N_name)s", # Unique constraint - supports multi-column
|
|
"ck": "ck_%(table_name)s_%(constraint_name)s", # Check constraint
|
|
"fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s", # Foreign key - supports composite keys
|
|
"pk": "pk_%(table_name)s", # Primary key
|
|
}
|
|
|
|
table_schema = settings.DB.SCHEMA
|
|
# Note: column_0_N_name expands to include all columns in multi-column constraints
|
|
# e.g., "workspace_id_tenant_id" for a composite constraint on both columns
|
|
meta = MetaData(naming_convention=convention)
|
|
meta.schema = table_schema
|
|
Base = declarative_base(metadata=meta)
|
|
|
|
|
|
async def init_db():
|
|
"""Initialize the database using Alembic migrations"""
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
async with engine.connect() as connection:
|
|
# Create schema if it doesn't exist
|
|
await connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{table_schema}"'))
|
|
# Install pgvector extension if it doesn't exist
|
|
await connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
|
await connection.commit()
|
|
|
|
# Run Alembic migrations
|
|
alembic_cfg = Config("alembic.ini")
|
|
command.upgrade(alembic_cfg, "head")
|