honcho/src/db.py

408 lines
17 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_connections_established_counter,
db_connections_open_gauge,
db_queries_in_flight_gauge,
)
logger = logging.getLogger(__name__)
connect_args: dict[str, Any] = {
"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_hnsw_iterative_scan_on_connect(
dbapi_connection: Any, _connection_record: Any
) -> None:
"""Apply pgvector HNSW iterative scan GUC per-connection.
Registered when ``DB.HNSW_ITERATIVE_SCAN`` is set. Fires once per new
pool connection so filtered HNSW queries scan additional candidates
instead of silently under-returning when out-of-scope rows consume the
initial scan budget. Note: pgvector may still stop before reaching top_k
if ``hnsw.max_scan_tuples`` or ``hnsw.scan_mem_multiplier`` thresholds are
exceeded. Uses ``set_config`` with a bind parameter (same pattern as
``_set_application_name_on_checkout``) rather than an f-string ``SET``
to avoid special-casing utility-statement parameter binding.
Runs in autocommit so it never leaves the connection 'idle in
transaction': 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.
"""
value = settings.DB.HNSW_ITERATIVE_SCAN
if not value:
return
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('hnsw.iterative_scan', %s, false)",
(value,),
)
finally:
cursor.close()
finally:
if not previous_autocommit:
dbapi_connection.autocommit = False
except Exception:
logger.debug("setting hnsw.iterative_scan on connect failed", exc_info=True)
if settings.DB.HNSW_ITERATIVE_SCAN:
event.listen(engine.sync_engine, "connect", _set_hnsw_iterative_scan_on_connect)
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
class DBConnectionTracker:
"""Tracks physical DB connections open on this engine via pool lifecycle events.
Drift-proof, mirroring ``DBQueryInflightTracker``: marks the ``ConnectionRecord``
on ``connect`` and decrements only if that mark is still present on
``close``/``invalidate``, so each physical connection increments the gauge
exactly once and decrements at most once — it can't leak upward or go negative
when both events fire during invalidation cleanup. Works for every pool class,
including ``NullPool`` (whose pool keeps no records, so the scrape-time
``db_pool_connections`` collector reads zero).
"""
# Marker on ConnectionRecord.info recording that we incremented for this
# connection, so we decrement exactly once across close/invalidate.
OPEN_KEY: str = "_honcho_conn_open"
def __init__(self, open_child: Any, established_child: Any) -> None:
self._open: Any = open_child
self._established: Any = established_child
def on_connect(self, _dbapi_connection: Any, connection_record: Any) -> None:
try:
connection_record.info[self.OPEN_KEY] = True
self._established.inc()
self._open.inc()
except Exception:
logger.debug("db-connection gauge inc failed", exc_info=True)
def on_close(self, _dbapi_connection: Any, connection_record: Any, *_: Any) -> None:
try:
if connection_record is not None and connection_record.info.pop(
self.OPEN_KEY, False
):
self._open.dec()
except Exception:
logger.debug("db-connection gauge dec failed", exc_info=True)
# Process-wide tracker, created at registration (None until then / if metrics off).
_connection_tracker: DBConnectionTracker | None = None
_db_connection_instrumentation_registered = False
def register_db_connection_instrumentation(instance_type: str) -> None:
"""Attach physical-connection tracking to the engine (no-op if metrics off).
Counts connections via pool lifecycle events, so it reports real numbers under
any pool class — unlike the pool-object collector, which reads zero under
``NullPool``. Pre-resolving the labeled children materializes both series at 0,
so an absent series signals a broken scrape rather than "no connections" (the
zero-init convention). Idempotent: repeated calls won't attach duplicate
listeners, which would double-count connections.
"""
global _connection_tracker, _db_connection_instrumentation_registered
if not settings.METRICS.ENABLED or _db_connection_instrumentation_registered:
return
open_child = db_connections_open_gauge.labels(instance_type=instance_type)
established_child = db_connections_established_counter.labels(
instance_type=instance_type
)
_connection_tracker = DBConnectionTracker(open_child, established_child)
sync_engine = engine.sync_engine
event.listen(sync_engine, "connect", _connection_tracker.on_connect)
# A connection is torn down by close (normal return / recycle discard),
# invalidate (broken connection), or detach — the last fires on GC-cleanup of an
# abandoned async connection, where NullPool's close is a no-op so `close` never
# fires. All three carry the ConnectionRecord; the marker dedupes if more than
# one fires for the same connection.
for teardown_event in ("close", "invalidate", "detach"):
event.listen(sync_engine, teardown_event, _connection_tracker.on_close)
_db_connection_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()
# Validate pgvector version when HNSW iterative scan is enabled
# (requires pgvector >= 0.8.0). Fail startup with a clear error
# rather than relying on silent query-time failures.
# Skip the check for "off" — listener is still registered so the
# application can override a server-level setting, but no version
# requirement applies.
if settings.DB.HNSW_ITERATIVE_SCAN in ("strict_order", "relaxed_order"):
async with engine.connect() as connection:
result = await connection.execute(
text("SELECT extversion FROM pg_extension WHERE extname = 'vector'")
)
row = result.fetchone()
if row is not None:
version_str = row[0]
version_parts = version_str.split(".")
major = int(version_parts[0]) if len(version_parts) > 0 else 0
minor = int(version_parts[1]) if len(version_parts) > 1 else 0
if (major, minor) < (0, 8):
raise RuntimeError(
"pgvector version "
+ version_str
+ " is installed but HNSW_ITERATIVE_SCAN"
+ " requires pgvector >= 0.8.0."
+ " Upgrade pgvector or set HNSW_ITERATIVE_SCAN=off."
)
# Run Alembic migrations
alembic_cfg = Config("alembic.ini")
command.upgrade(alembic_cfg, "head")