Skip to content

Commit 80aea8f

Browse files
feat(server): bound database connection pool (#236)
1 parent 09c5a54 commit 80aea8f

8 files changed

Lines changed: 458 additions & 39 deletions

File tree

server/src/agent_control_server/config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,41 @@ class AgentControlServerDatabaseConfig(BaseSettings):
125125
"DB_DATABASE",
126126
)
127127
driver: str = _env_alias_field("psycopg", "AGENT_CONTROL_DB_DRIVER", "DB_DRIVER")
128+
pool_size: int = Field(
129+
default=5,
130+
ge=1,
131+
validation_alias=AliasChoices("AGENT_CONTROL_DB_POOL_SIZE", "DB_POOL_SIZE"),
132+
)
133+
max_overflow: int = Field(
134+
default=10,
135+
ge=0,
136+
validation_alias=AliasChoices("AGENT_CONTROL_DB_MAX_OVERFLOW", "DB_MAX_OVERFLOW"),
137+
)
138+
pool_timeout_seconds: float = Field(
139+
default=5.0,
140+
gt=0,
141+
validation_alias=AliasChoices(
142+
"AGENT_CONTROL_DB_POOL_TIMEOUT_SECONDS",
143+
"DB_POOL_TIMEOUT_SECONDS",
144+
),
145+
)
146+
connect_timeout_seconds: int = Field(
147+
default=5,
148+
ge=1,
149+
validation_alias=AliasChoices(
150+
"AGENT_CONTROL_DB_CONNECT_TIMEOUT_SECONDS",
151+
"DB_CONNECT_TIMEOUT_SECONDS",
152+
),
153+
)
154+
# 0 disables the server-side statement timeout.
155+
statement_timeout_seconds: float = Field(
156+
default=50.0,
157+
ge=0,
158+
validation_alias=AliasChoices(
159+
"AGENT_CONTROL_DB_STATEMENT_TIMEOUT_SECONDS",
160+
"DB_STATEMENT_TIMEOUT_SECONDS",
161+
),
162+
)
128163

129164
def get_url(self) -> str:
130165
"""Get database URL, preferring an explicit URL if configured."""

server/src/agent_control_server/db.py

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import logging
12
from collections.abc import AsyncGenerator
3+
from typing import Any
24

5+
from prometheus_client import Gauge
6+
from sqlalchemy.engine.url import make_url
37
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
8+
from sqlalchemy.ext.asyncio.engine import AsyncEngine
49
from sqlalchemy.orm import DeclarativeBase
510

6-
from .config import db_config
11+
from .config import AgentControlServerDatabaseConfig, db_config
12+
13+
logger = logging.getLogger(__name__)
714

815

916
class Base(DeclarativeBase):
@@ -13,11 +20,91 @@ class Base(DeclarativeBase):
1320
# Async SQLAlchemy setup for PostgreSQL
1421
db_url = db_config.get_url()
1522

16-
async_engine = create_async_engine(
17-
db_url,
18-
echo=False,
23+
SQLALCHEMY_CHECKED_OUT_CONNECTIONS = Gauge(
24+
"agent_control_server_sqlalchemy_checked_out_connections",
25+
"Number of checked out SQLAlchemy connections.",
26+
["pool_name"],
27+
multiprocess_mode="livesum",
1928
)
2029

30+
31+
def _supports_queue_pool_config(url: str) -> bool:
32+
"""Return whether SQLAlchemy QueuePool kwargs should be applied for this URL."""
33+
return make_url(url).get_backend_name() != "sqlite"
34+
35+
36+
def _build_connect_args(
37+
url: str,
38+
config: AgentControlServerDatabaseConfig,
39+
) -> dict[str, Any]:
40+
"""Build driver-level connect args bounding connection setup and statement runtime.
41+
42+
Pool timeouts only bound how long a request waits for a connection; these
43+
args bound how long a connection takes to establish and how long any one
44+
statement may hold it. Drivers without known timeout args get none.
45+
"""
46+
driver = make_url(url).get_driver_name()
47+
statement_timeout_ms = int(config.statement_timeout_seconds * 1000)
48+
if driver == "psycopg":
49+
connect_args: dict[str, Any] = {"connect_timeout": config.connect_timeout_seconds}
50+
if statement_timeout_ms:
51+
connect_args["options"] = f"-c statement_timeout={statement_timeout_ms}"
52+
return connect_args
53+
if driver == "asyncpg":
54+
connect_args = {"timeout": float(config.connect_timeout_seconds)}
55+
if statement_timeout_ms:
56+
connect_args["server_settings"] = {"statement_timeout": str(statement_timeout_ms)}
57+
return connect_args
58+
return {}
59+
60+
61+
def _build_async_engine_kwargs(
62+
url: str,
63+
config: AgentControlServerDatabaseConfig,
64+
) -> dict[str, Any]:
65+
"""Build async SQLAlchemy engine kwargs from database config."""
66+
kwargs: dict[str, Any] = {"echo": False}
67+
if not _supports_queue_pool_config(url):
68+
return kwargs
69+
70+
kwargs.update(
71+
pool_pre_ping=True,
72+
pool_size=config.pool_size,
73+
max_overflow=config.max_overflow,
74+
pool_timeout=config.pool_timeout_seconds,
75+
pool_reset_on_return="rollback",
76+
)
77+
connect_args = _build_connect_args(url, config)
78+
if connect_args:
79+
kwargs["connect_args"] = connect_args
80+
else:
81+
parsed_url = make_url(url)
82+
logger.debug(
83+
"No driver-level database timeout connect args configured for backend=%s driver=%s",
84+
parsed_url.get_backend_name(),
85+
parsed_url.get_driver_name(),
86+
)
87+
return kwargs
88+
89+
90+
def _checked_out_connection_count(engine: AsyncEngine) -> float:
91+
"""Return the current checked-out connection count when the pool exposes it."""
92+
checkedout = getattr(engine.sync_engine.pool, "checkedout", None)
93+
if not callable(checkedout):
94+
return 0.0
95+
return float(checkedout())
96+
97+
98+
def _instrument_connection_pool(engine: AsyncEngine) -> None:
99+
"""Report checked-out connections from the async engine's underlying pool."""
100+
SQLALCHEMY_CHECKED_OUT_CONNECTIONS.labels("default").set_function(
101+
lambda: _checked_out_connection_count(engine)
102+
)
103+
104+
105+
async_engine = create_async_engine(db_url, **_build_async_engine_kwargs(db_url, db_config))
106+
_instrument_connection_pool(async_engine)
107+
21108
AsyncSessionLocal = async_sessionmaker(
22109
bind=async_engine,
23110
autoflush=False,

server/src/agent_control_server/endpoints/evaluation.py

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@
1313
from agent_control_models.errors import ErrorCode, ValidationErrorItem
1414
from fastapi import APIRouter, Depends, Request
1515
from sqlalchemy import select
16-
from sqlalchemy.ext.asyncio import AsyncSession
1716

1817
from ..auth_framework import Operation, Principal, require_operation
19-
from ..db import get_async_db
18+
from ..db import AsyncSessionLocal
2019
from ..errors import APIValidationError, NotFoundError
2120
from ..logging_utils import get_logger
2221
from ..models import Agent
@@ -136,6 +135,41 @@ async def _evaluation_context(request: Request) -> dict[str, object]:
136135
return {"target_type": target_type, "target_id": target_id}
137136

138137

138+
async def _load_engine_controls(
139+
request: EvaluationRequest,
140+
principal: Principal,
141+
) -> list[ControlAdapter]:
142+
"""Load and materialize controls before evaluator execution starts."""
143+
namespace_key = principal.namespace_key
144+
145+
async with AsyncSessionLocal() as db:
146+
agent_result = await db.execute(
147+
select(Agent).where(
148+
Agent.name == request.agent_name,
149+
Agent.namespace_key == namespace_key,
150+
)
151+
)
152+
agent = agent_result.scalar_one_or_none()
153+
if agent is None:
154+
raise NotFoundError(
155+
error_code=ErrorCode.AGENT_NOT_FOUND,
156+
detail=f"Agent '{request.agent_name}' not found",
157+
resource="Agent",
158+
resource_id=request.agent_name,
159+
hint="Register the agent via initAgent before evaluating.",
160+
)
161+
162+
runtime_controls = await ControlService(db).list_runtime_controls_for_agent(
163+
request.agent_name,
164+
namespace_key=namespace_key,
165+
target_type=request.target_type,
166+
target_id=request.target_id,
167+
allow_invalid_step_name_regex=True,
168+
)
169+
170+
return [ControlAdapter(c.id, c.name, c.control) for c in runtime_controls]
171+
172+
139173
@router.post(
140174
"",
141175
response_model=EvaluationResponse,
@@ -144,7 +178,6 @@ async def _evaluation_context(request: Request) -> dict[str, object]:
144178
)
145179
async def evaluate(
146180
request: EvaluationRequest,
147-
db: AsyncSession = Depends(get_async_db),
148181
principal: Principal = Depends(
149182
require_operation(Operation.RUNTIME_USE, context_builder=_evaluation_context)
150183
),
@@ -163,34 +196,7 @@ async def evaluate(
163196
on the server; SDKs reconstruct and emit those events separately through
164197
the observability ingestion endpoint.
165198
"""
166-
namespace_key = principal.namespace_key
167-
168-
agent_result = await db.execute(
169-
select(Agent).where(
170-
Agent.name == request.agent_name,
171-
Agent.namespace_key == namespace_key,
172-
)
173-
)
174-
agent = agent_result.scalar_one_or_none()
175-
if agent is None:
176-
raise NotFoundError(
177-
error_code=ErrorCode.AGENT_NOT_FOUND,
178-
detail=f"Agent '{request.agent_name}' not found",
179-
resource="Agent",
180-
resource_id=request.agent_name,
181-
hint="Register the agent via initAgent before evaluating.",
182-
)
183-
184-
runtime_controls = await ControlService(db).list_runtime_controls_for_agent(
185-
request.agent_name,
186-
namespace_key=namespace_key,
187-
target_type=request.target_type,
188-
target_id=request.target_id,
189-
allow_invalid_step_name_regex=True,
190-
)
191-
192-
engine_controls = [ControlAdapter(c.id, c.name, c.control) for c in runtime_controls]
193-
199+
engine_controls = await _load_engine_controls(request, principal)
194200
engine = ControlEngine(engine_controls)
195201
try:
196202
raw_response = await engine.process(request)

server/src/agent_control_server/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from . import __version__ as server_version
2020
from .auth import get_api_key_from_header
2121
from .config import observability_settings, settings
22-
from .db import AsyncSessionLocal
22+
from .db import AsyncSessionLocal, async_engine
2323
from .endpoints.agents import router as agent_router
2424
from .endpoints.auth import router as auth_router
2525
from .endpoints.control_bindings import router as control_binding_router
@@ -198,6 +198,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
198198
await app.state.event_store.close()
199199
logger.info("EventStore closed")
200200

201+
await async_engine.dispose()
202+
logger.info("Database engine disposed")
203+
201204

202205
app = FastAPI(
203206
title="Agent Control Server",

server/tests/test_config.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,48 @@ def test_db_config_ignores_blank_agent_control_url_and_uses_legacy(monkeypatch)
8989
assert config.get_url() == "sqlite:///tmp/legacy.db"
9090

9191

92+
def test_db_config_reads_pool_settings_from_env(monkeypatch) -> None:
93+
# Given: database pool settings are configured via environment variables
94+
monkeypatch.setenv("AGENT_CONTROL_DB_POOL_SIZE", "7")
95+
monkeypatch.setenv("AGENT_CONTROL_DB_MAX_OVERFLOW", "2")
96+
monkeypatch.setenv("AGENT_CONTROL_DB_POOL_TIMEOUT_SECONDS", "3.5")
97+
monkeypatch.setenv("AGENT_CONTROL_DB_CONNECT_TIMEOUT_SECONDS", "4")
98+
monkeypatch.setenv("AGENT_CONTROL_DB_STATEMENT_TIMEOUT_SECONDS", "2.5")
99+
100+
# When: loading DB config from the environment
101+
config = AgentControlServerDatabaseConfig()
102+
103+
# Then: the explicit pool settings are used
104+
assert config.pool_size == 7
105+
assert config.max_overflow == 2
106+
assert config.pool_timeout_seconds == 3.5
107+
assert config.connect_timeout_seconds == 4
108+
assert config.statement_timeout_seconds == 2.5
109+
110+
111+
def test_db_config_pool_defaults(monkeypatch) -> None:
112+
# Given: no pool or timeout settings in the environment
113+
for name in (
114+
"POOL_SIZE",
115+
"MAX_OVERFLOW",
116+
"POOL_TIMEOUT_SECONDS",
117+
"CONNECT_TIMEOUT_SECONDS",
118+
"STATEMENT_TIMEOUT_SECONDS",
119+
):
120+
monkeypatch.delenv(f"AGENT_CONTROL_DB_{name}", raising=False)
121+
monkeypatch.delenv(f"DB_{name}", raising=False)
122+
123+
# When: loading DB config from the environment
124+
config = AgentControlServerDatabaseConfig()
125+
126+
# Then: the pool is bounded but keeps burst overflow and sane timeouts
127+
assert config.pool_size == 5
128+
assert config.max_overflow == 10
129+
assert config.pool_timeout_seconds == 5.0
130+
assert config.connect_timeout_seconds == 5
131+
assert config.statement_timeout_seconds == 50.0
132+
133+
92134
def test_settings_parses_cors_origins_string() -> None:
93135
# Given: a comma-separated CORS origins string
94136
settings = Settings(cors_origins="https://a.example, https://b.example")

0 commit comments

Comments
 (0)