Skip to content

Commit d85ba27

Browse files
committed
fix/added pool size, overflow, timeout and recycle configs
1 parent ea5ccb7 commit d85ba27

6 files changed

Lines changed: 97 additions & 1 deletion

File tree

app/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ class DatabaseSettings(BaseModel):
5555
"""Database connection settings."""
5656

5757
url: str = Field(description="Async SQLAlchemy URL using asyncpg driver.")
58+
pool_size: int = Field(default=20, ge=1)
59+
max_overflow: int = Field(default=20, ge=0)
60+
pool_timeout_seconds: int = Field(default=30, ge=1)
61+
pool_recycle_seconds: int = Field(default=1800, ge=1)
5862

5963
@field_validator("url")
6064
@classmethod

app/db/session.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ async def _dispose_async_engine(engine: AsyncEngine) -> None:
2323
def get_engine() -> AsyncEngine:
2424
"""Build and cache the async SQLAlchemy engine."""
2525
settings = get_settings()
26-
return create_async_engine(settings.database.url, pool_pre_ping=True)
26+
return create_async_engine(
27+
settings.database.url,
28+
pool_pre_ping=True,
29+
pool_size=settings.database.pool_size,
30+
max_overflow=settings.database.max_overflow,
31+
pool_timeout=settings.database.pool_timeout_seconds,
32+
pool_recycle=settings.database.pool_recycle_seconds,
33+
)
2734

2835

2936
@reloadable_singleton

docs/configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ Forbidden in production:
9898

9999
- `DATABASE__URL`
100100
must use the `postgresql+asyncpg://` SQLAlchemy URL form
101+
- `DATABASE__POOL_SIZE`
102+
steady-state SQLAlchemy pool size, defaults to `20`
103+
- `DATABASE__MAX_OVERFLOW`
104+
extra connections permitted above `DATABASE__POOL_SIZE`, defaults to `20`
105+
- `DATABASE__POOL_TIMEOUT_SECONDS`
106+
how long to wait for a pooled connection before failing, defaults to `30`
107+
- `DATABASE__POOL_RECYCLE_SECONDS`
108+
maximum connection age before SQLAlchemy recycles it on checkout, defaults to `1800`
101109

102110
### Redis
103111

docs/operations.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ Tune these if the Redis path is behind an idle-sensitive proxy or load balancer:
125125
The worker intentionally keeps its blocking dequeue window below common managed
126126
Redis idle timeouts.
127127

128+
## Database Pool Tuning
129+
130+
The API uses explicit SQLAlchemy pool tuning rather than library defaults. The
131+
main knobs are:
132+
133+
- `DATABASE__POOL_SIZE`
134+
- `DATABASE__MAX_OVERFLOW`
135+
- `DATABASE__POOL_TIMEOUT_SECONDS`
136+
- `DATABASE__POOL_RECYCLE_SECONDS`
137+
138+
`DATABASE__POOL_RECYCLE_SECONDS` is especially important for managed Postgres
139+
providers and proxies that silently drop older idle connections.
140+
128141
## Retention Purge
129142

130143
The scheduler registers the recurring retention-purge job only when:

tests/unit/test_config_production_hardening.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,33 @@ def test_development_settings_allow_empty_prod_only_fields() -> None:
7676
settings = _build_settings()
7777
assert settings.app.environment == "development"
7878
assert settings.app.allowed_hosts == []
79+
assert settings.database.pool_size == 20
80+
assert settings.database.max_overflow == 20
81+
assert settings.database.pool_timeout_seconds == 30
82+
assert settings.database.pool_recycle_seconds == 1800
7983
assert settings.session_security.refresh_token_hash_key is None
8084
assert settings.signing_keys.encryption_key is None
8185
assert settings.webhook.secret_encryption_key is None
8286

8387

88+
@pytest.mark.parametrize(
89+
("field_name", "value"),
90+
[
91+
("pool_size", 0),
92+
("max_overflow", -1),
93+
("pool_timeout_seconds", 0),
94+
("pool_recycle_seconds", 0),
95+
],
96+
)
97+
def test_database_settings_reject_invalid_pool_tuning(field_name: str, value: int) -> None:
98+
"""Database pool tuning should reject invalid non-positive values."""
99+
with pytest.raises(ValueError):
100+
DatabaseSettings(
101+
url="postgresql+asyncpg://user:pass@db.example.com:5432/auth_service",
102+
**{field_name: value},
103+
)
104+
105+
84106
@pytest.mark.parametrize(
85107
("mutate", "message"),
86108
[

tests/unit/test_reloadable_singletons.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ def _seed_minimal_env(
4040
monkeypatch: pytest.MonkeyPatch,
4141
*,
4242
database_url: str = "postgresql+asyncpg://user:pass@db.example.com:5432/auth_service",
43+
database_pool_size: str = "20",
44+
database_max_overflow: str = "20",
45+
database_pool_timeout_seconds: str = "30",
46+
database_pool_recycle_seconds: str = "1800",
4347
redis_url: str = "redis://redis.example.com:6379/0",
4448
access_ttl_seconds: str = "900",
4549
refresh_ttl_seconds: str = "604800",
@@ -48,6 +52,10 @@ def _seed_minimal_env(
4852
monkeypatch.setenv("APP__ENVIRONMENT", "development")
4953
monkeypatch.setenv("APP__SERVICE", "auth-service")
5054
monkeypatch.setenv("DATABASE__URL", database_url)
55+
monkeypatch.setenv("DATABASE__POOL_SIZE", database_pool_size)
56+
monkeypatch.setenv("DATABASE__MAX_OVERFLOW", database_max_overflow)
57+
monkeypatch.setenv("DATABASE__POOL_TIMEOUT_SECONDS", database_pool_timeout_seconds)
58+
monkeypatch.setenv("DATABASE__POOL_RECYCLE_SECONDS", database_pool_recycle_seconds)
5159
monkeypatch.setenv("REDIS__URL", redis_url)
5260
monkeypatch.setenv("JWT__ALGORITHM", "RS256")
5361
monkeypatch.setenv("JWT__PRIVATE_KEY_PEM", "private-key")
@@ -198,6 +206,40 @@ def _create_async_engine(url: str, **kwargs: object) -> _AsyncEngineStub:
198206
]
199207

200208

209+
def test_get_engine_applies_configured_pool_tuning(monkeypatch: pytest.MonkeyPatch) -> None:
210+
"""Engine factory should pass validated pool tuning to SQLAlchemy."""
211+
_seed_minimal_env(
212+
monkeypatch,
213+
database_pool_size="32",
214+
database_max_overflow="48",
215+
database_pool_timeout_seconds="45",
216+
database_pool_recycle_seconds="2700",
217+
)
218+
_clear_reloadable_getters()
219+
captured: dict[str, object] = {}
220+
221+
def _create_async_engine(url: str, **kwargs: object) -> _AsyncEngineStub:
222+
captured["url"] = url
223+
captured["kwargs"] = dict(kwargs)
224+
return _AsyncEngineStub(url)
225+
226+
monkeypatch.setattr(db_session_module, "create_async_engine", _create_async_engine)
227+
228+
engine = db_session_module.get_engine()
229+
230+
assert engine is not None
231+
assert captured == {
232+
"url": "postgresql+asyncpg://user:pass@db.example.com:5432/auth_service",
233+
"kwargs": {
234+
"pool_pre_ping": True,
235+
"pool_size": 32,
236+
"max_overflow": 48,
237+
"pool_timeout": 45,
238+
"pool_recycle": 2700,
239+
},
240+
}
241+
242+
201243
@pytest.mark.asyncio
202244
async def test_get_session_service_refreshes_nested_dependencies_when_settings_change(
203245
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)