Skip to content

Commit b81eee9

Browse files
committed
Merge branch 'main' of github.com:dialvarezs/listestar-nuxt-fullstack
2 parents 2dfc1b0 + cbc4016 commit b81eee9

File tree

4 files changed

+83
-35
lines changed

4 files changed

+83
-35
lines changed

app_api/app/db.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,32 @@
1616
from app.config import Settings, settings
1717

1818

19-
def create_sqlalchemy_config(app_settings: Settings | None = None) -> SQLAlchemyAsyncConfig:
19+
def create_sqlalchemy_config(
20+
app_settings: Settings | None = None, pool_size: int | None = None, max_overflow: int | None = None
21+
) -> SQLAlchemyAsyncConfig:
2022
"""
2123
Create SQLAlchemy configuration with the given settings.
2224
2325
Args:
2426
app_settings: Settings instance to use. If None, uses global settings.
27+
pool_size: Size of the connection pool. If None, uses SQLAlchemy default.
28+
max_overflow: Maximum overflow size. If None, uses SQLAlchemy default.
2529
2630
Returns:
2731
SQLAlchemy async configuration instance
2832
"""
2933
if app_settings is None:
3034
app_settings = settings
3135

36+
engine_config_kwargs: dict = {"echo": False}
37+
if pool_size is not None:
38+
engine_config_kwargs["pool_size"] = pool_size
39+
if max_overflow is not None:
40+
engine_config_kwargs["max_overflow"] = max_overflow
41+
3242
return SQLAlchemyAsyncConfig(
3343
connection_string=app_settings.database_url.unicode_string(),
34-
engine_config=EngineConfig(echo=False),
44+
engine_config=EngineConfig(**engine_config_kwargs),
3545
session_config=AsyncSessionConfig(expire_on_commit=False),
3646
before_send_handler="autocommit",
3747
alembic_config=AlembicAsyncConfig(
@@ -40,17 +50,21 @@ def create_sqlalchemy_config(app_settings: Settings | None = None) -> SQLAlchemy
4050
)
4151

4252

43-
def create_sqlalchemy_plugin(app_settings: Settings | None = None) -> SQLAlchemyPlugin:
53+
def create_sqlalchemy_plugin(
54+
app_settings: Settings | None = None, pool_size: int | None = None, max_overflow: int | None = None
55+
) -> SQLAlchemyPlugin:
4456
"""
4557
Create SQLAlchemy plugin with the given settings.
4658
4759
Args:
4860
app_settings: Settings instance to use. If None, uses global settings.
61+
pool_size: Size of the connection pool. If None, uses SQLAlchemy default.
62+
max_overflow: Maximum overflow size. If None, uses SQLAlchemy default.
4963
5064
Returns:
5165
SQLAlchemy plugin instance (config accessible via plugin.config[0])
5266
"""
53-
config = create_sqlalchemy_config(app_settings)
67+
config = create_sqlalchemy_config(app_settings, pool_size=pool_size, max_overflow=max_overflow)
5468
return SQLAlchemyPlugin(config=config)
5569

5670

app_api/app/main.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919
from app.api.accounts.auth.security import create_oauth2_auth
2020
from app.api.accounts.router import accounts_router
2121
from app.config import Settings, settings
22-
from app.db import create_sqlalchemy_config, create_sqlalchemy_plugin
22+
from app.db import create_sqlalchemy_plugin
2323

2424

2525
def create_app(
2626
app_settings: Settings | None = None,
2727
title: str = "PM API",
2828
enable_structlog: bool = True,
29+
pool_size: int | None = None,
30+
max_overflow: int | None = None,
2931
) -> Litestar:
3032
"""
3133
Create and configure a Litestar application instance.
@@ -38,15 +40,19 @@ def create_app(
3840
app_settings: Settings instance to use. If None, uses global settings.
3941
title: Title for the OpenAPI documentation
4042
enable_structlog: Whether to enable structlog logging plugin
43+
pool_size: Database connection pool size. If None, uses SQLAlchemy default.
44+
max_overflow: Database connection pool max overflow. If None, uses SQLAlchemy default.
4145
4246
Returns:
4347
Configured Litestar application instance
4448
"""
4549
if app_settings is None:
4650
app_settings = settings
4751

48-
app_sqlalchemy_plugin = create_sqlalchemy_plugin(app_settings)
49-
app_sqlalchemy_config = create_sqlalchemy_config(app_settings)
52+
app_sqlalchemy_plugin = create_sqlalchemy_plugin(
53+
app_settings, pool_size=pool_size, max_overflow=max_overflow
54+
)
55+
app_sqlalchemy_config = app_sqlalchemy_plugin.config[0]
5056

5157
openapi_config = OpenAPIConfig(
5258
title=title,
@@ -63,7 +69,7 @@ def create_app(
6369
),
6470
enable_middleware_logging=app_settings.debug,
6571
middleware_logging_config=LoggingMiddlewareConfig(
66-
response_log_fields=["status_code", "cookies", "headers"],
72+
response_log_fields=("status_code", "cookies", "headers"),
6773
),
6874
)
6975
)

app_api/tests/accounts/conftest.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest_asyncio
1111
from litestar.testing.client import AsyncTestClient
1212
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
13+
from sqlalchemy.pool import NullPool
1314

1415
from app.api.accounts.users.services import password_hasher
1516
from app.models.accounts import Permission, Role, User
@@ -149,7 +150,7 @@ async def client_with_roles(
149150
client: AsyncTestClient, session_database: dict[str, str]
150151
) -> AsyncIterator[AsyncTestClient]:
151152
"""Create a test client with pre-populated roles."""
152-
engine = create_async_engine(session_database["url"], echo=False)
153+
engine = create_async_engine(session_database["url"], echo=False, poolclass=NullPool)
153154
try:
154155
await _populate_roles(engine)
155156
yield client
@@ -162,7 +163,7 @@ async def client_with_accounts(
162163
client: AsyncTestClient, session_database: dict[str, str]
163164
) -> AsyncIterator[AsyncTestClient]:
164165
"""Create a test client with pre-populated users and roles."""
165-
engine = create_async_engine(session_database["url"], echo=False)
166+
engine = create_async_engine(session_database["url"], echo=False, poolclass=NullPool)
166167
try:
167168
await _populate_accounts(engine)
168169
yield client
@@ -175,7 +176,7 @@ async def authenticated_client(
175176
client: AsyncTestClient, session_database: dict[str, str]
176177
) -> AsyncIterator[AsyncTestClient]:
177178
"""Create a test client with pre-populated users and roles, and authenticate a user."""
178-
engine = create_async_engine(session_database["url"], echo=False)
179+
engine = create_async_engine(session_database["url"], echo=False, poolclass=NullPool)
179180
try:
180181
await _populate_accounts(engine)
181182

@@ -204,7 +205,7 @@ async def client_with_permissions(
204205
) -> AsyncIterator[AsyncTestClient]:
205206
"""Create a test client with pre-populated users, roles, and permissions."""
206207

207-
engine = create_async_engine(session_database["url"], echo=False)
208+
engine = create_async_engine(session_database["url"], echo=False, poolclass=NullPool)
208209
try:
209210
await _populate_permissions(engine)
210211
yield authenticated_client

app_api/tests/conftest.py

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pytest_databases.docker.postgres import PostgresService
77
from sqlalchemy import text
88
from sqlalchemy.ext.asyncio import create_async_engine
9+
from sqlalchemy.pool import NullPool
910

1011
from app.config import Settings
1112

@@ -28,51 +29,74 @@ async def session_database(
2829
test_db_url = f"{base_url}/{db_name}"
2930

3031
# Connect to default postgres db to create our session test database
31-
default_engine = create_async_engine(f"{base_url}/postgres", echo=False, isolation_level="AUTOCOMMIT")
32+
default_engine = create_async_engine(
33+
f"{base_url}/postgres", echo=False, isolation_level="AUTOCOMMIT", poolclass=NullPool
34+
)
3235
try:
3336
async with default_engine.connect() as conn:
3437
await conn.execute(text(f"CREATE DATABASE {db_name}"))
3538
finally:
3639
await default_engine.dispose()
3740

3841
# Create database tables
39-
engine = create_async_engine(test_db_url, echo=False)
42+
engine = create_async_engine(test_db_url, echo=False, poolclass=NullPool)
4043
try:
4144
async with engine.begin() as conn:
4245
assert sqlalchemy_config.metadata is not None
4346
await conn.run_sync(sqlalchemy_config.metadata.create_all)
44-
45-
# Yield the database info for the session
46-
yield {"url": test_db_url, "db_name": db_name, "base_url": base_url}
47-
4847
finally:
4948
await engine.dispose()
5049

51-
# Drop the session test database
52-
cleanup_engine = create_async_engine(f"{base_url}/postgres", echo=False, isolation_level="AUTOCOMMIT")
53-
try:
54-
async with cleanup_engine.connect() as conn:
55-
# Terminate connections to the test database first
56-
await conn.execute(
57-
text(f"""
58-
SELECT pg_terminate_backend(pid)
59-
FROM pg_stat_activity
60-
WHERE datname = '{db_name}' AND pid <> pg_backend_pid()
61-
""")
62-
)
63-
await conn.execute(text(f"DROP DATABASE IF EXISTS {db_name}"))
64-
finally:
65-
await cleanup_engine.dispose()
50+
# Yield the database info for the session
51+
yield {"url": test_db_url, "db_name": db_name, "base_url": base_url}
52+
53+
# Drop the session test database
54+
cleanup_engine = create_async_engine(
55+
f"{base_url}/postgres", echo=False, isolation_level="AUTOCOMMIT", poolclass=NullPool
56+
)
57+
try:
58+
async with cleanup_engine.connect() as conn:
59+
# Terminate connections to the test database first
60+
await conn.execute(
61+
text(f"""
62+
SELECT pg_terminate_backend(pid)
63+
FROM pg_stat_activity
64+
WHERE datname = '{db_name}' AND pid <> pg_backend_pid()
65+
""")
66+
)
67+
await conn.execute(text(f"DROP DATABASE IF EXISTS {db_name}"))
68+
finally:
69+
await cleanup_engine.dispose()
6670

6771

6872
@pytest_asyncio.fixture(scope="function")
6973
async def clean_database(session_database: dict[str, str]) -> AsyncIterator[None]:
70-
"""Clean database between tests by truncating all tables."""
74+
"""
75+
Clean database between tests by truncating all tables.
76+
77+
Uses two engines to avoid connection leaks:
78+
1. terminator_engine: Kills orphaned connections to test DB (connects to postgres db)
79+
2. clean_engine: Performs TRUNCATE operations (connects to test db)
80+
"""
7181
from app.db import sqlalchemy_config
7282

73-
clean_engine = create_async_engine(session_database["url"], echo=False)
83+
terminator_engine = create_async_engine(
84+
f"{session_database['base_url']}/postgres",
85+
echo=False,
86+
isolation_level="AUTOCOMMIT",
87+
poolclass=NullPool,
88+
)
89+
clean_engine = create_async_engine(session_database["url"], echo=False, poolclass=NullPool)
7490

7591
try:
92+
# Terminate stray connections from previous tests to prevent connection pool exhaustion
93+
async with terminator_engine.connect() as conn:
94+
await conn.execute(
95+
text(
96+
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{session_database['db_name']}' AND pid <> pg_backend_pid()"
97+
)
98+
)
99+
76100
# Clean tables before the test
77101
async with clean_engine.begin() as conn:
78102
assert sqlalchemy_config.metadata is not None
@@ -83,6 +107,7 @@ async def clean_database(session_database: dict[str, str]) -> AsyncIterator[None
83107

84108
finally:
85109
await clean_engine.dispose()
110+
await terminator_engine.dispose()
86111

87112

88113
@pytest_asyncio.fixture(scope="function")
@@ -123,6 +148,8 @@ def settings_customise_sources(
123148
app_settings=test_settings,
124149
title="Test PM API",
125150
enable_structlog=False,
151+
pool_size=1,
152+
max_overflow=0,
126153
)
127154

128155
async with AsyncTestClient(app=test_app) as test_client:

0 commit comments

Comments
 (0)