Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/aignostics_foundry_core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei

- **Purpose**: Manages a process-level async database engine singleton, providing session injection for FastAPI routes, background jobs, and CLI commands. All public functions accept optional DB-config params and fall back to the active `FoundryContext.database` when they are `None`.
- **Key Features**:
- `init_engine(db_url=None, pool_size=None, pool_max_overflow=None, pool_timeout=None)` — initialises the global `AsyncEngine` and `async_sessionmaker`; subsequent calls are silent no-ops. When `db_url` is `None`, the URL and pool settings are resolved from `get_context().database`; raises `RuntimeError` if no context is installed or `ctx.database` is `None`. Pool parameters are omitted automatically for SQLite (which does not use `QueuePool`).
- `init_engine(db_url=None, pool_size=None, pool_max_overflow=None, pool_timeout=None)` — initialises the global `AsyncEngine` and `async_sessionmaker`; subsequent calls are silent no-ops. When `db_url` is `None`, the URL and pool settings are resolved from `get_context().database`; raises `RuntimeError` if no context is installed or `ctx.database` is `None`. Pool parameters are omitted automatically for SQLite (which does not use `QueuePool`). The engine sets `hide_parameters=True`, so SQLAlchemy error messages do not contain bound parameter values.
- `dispose_engine()` — async; disposes the engine; called during application shutdown.
- `get_db_session()` — async generator; yields an `AsyncSession`; raises `RuntimeError` if engine not initialised. Use as a FastAPI `Depends` target.
- `execute_with_session(async_func, *args, **kwargs)` — async; runs `async_func` with a session injected as the `session` keyword argument. For background jobs and CLI helpers.
Expand Down
5 changes: 5 additions & 0 deletions src/aignostics_foundry_core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ def init_engine(
Called during FastAPI lifespan startup or first job execution.
Subsequent calls are no-ops (engine is already initialized).

The engine hides bound parameter values in SQLAlchemy error messages, because
these messages go to logs and Sentry.

For multiprocessing: Engine is automatically reset in child processes via
multiprocessing.util.register_after_fork().

Expand Down Expand Up @@ -255,6 +258,8 @@ def init_engine(
"url": db_url,
"pool_pre_ping": True,
"echo": False,
# Keep bound parameter values out of error messages. They go to logs and Sentry.
"hide_parameters": True,
}
if not db_url.startswith("sqlite"):
engine_kwargs["pool_size"] = pool_size
Expand Down
17 changes: 17 additions & 0 deletions tests/aignostics_foundry_core/database_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from pathlib import Path

import pytest
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession

from aignostics_foundry_core.database import (
Expand All @@ -24,6 +26,7 @@
DB_URL_ERROR_FRAGMENT = "DB_URL"

SESSION_KWARG = "session"
SENSITIVE_PARAMETER_VALUE = "sensitive-parameter-value-vpthp-128"


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -74,6 +77,20 @@ async def test_init_engine_is_idempotent(self, sqlite_url: str) -> None:
init_engine(sqlite_url) # Must not raise; second call is a silent no-op
await execute_with_session(noop) # Session maker still functional

@pytest.mark.unit
async def test_failed_statement_error_hides_parameter_values(self, sqlite_url: str) -> None:
"""A failed statement gives an error message without the bound parameter values."""

async def select_from_missing_table(session: AsyncSession) -> None:
await session.execute(text("SELECT * FROM missing_table WHERE x = :v"), {"v": SENSITIVE_PARAMETER_VALUE})

init_engine(sqlite_url)
with pytest.raises(OperationalError) as exc_info:
await execute_with_session(select_from_missing_table)

assert SENSITIVE_PARAMETER_VALUE not in str(exc_info.value)
assert "hide_parameters=True" in str(exc_info.value)

@pytest.mark.unit
async def test_init_engine_non_sqlite_url_accepted(self) -> None:
"""init_engine accepts a non-SQLite URL without raising (engine creation is lazy)."""
Expand Down
Loading