Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions backend/app/core/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Settings(BaseSettings):
# DB configuration
supabase_url: str
supabase_key: str
database_url: Optional[str] = None

# LangSmith Tracing
langsmith_tracing: bool = False
Expand Down
57 changes: 57 additions & 0 deletions backend/app/database/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from typing import AsyncGenerator
from app.core.config import settings
import logging

logger = logging.getLogger(__name__)

# Database configuration
DATABASE_URL = settings.database_url

if not DATABASE_URL:
logger.warning("DATABASE_URL is not set. Database connection pooling will not be available.")
# Fallback or strict error depending on requirements.
# For now ensuring tests/code doesn't crash on import if env missing during build.
# But initialization should be guarded.

# Initialize SQLAlchemy Async Engine with pooling
# If DATABASE_URL is missing, engine will be None and get_db will fail/error out appropriately when called
engine = create_async_engine(
DATABASE_URL,
echo=False,
pool_size=20, # Maintain 20 open connections
max_overflow=10, # Allow 10 extra during spikes
pool_timeout=30, # Wait 30s for a connection before raising timeout
pool_pre_ping=True, # Check connection health before handing it out
) if DATABASE_URL else None

# Session Factory
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
) if engine else None


async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency to provide a thread-safe database session.
Ensures that the session is closed after the request is processed.
"""
if not async_session_maker:
raise RuntimeError("Database engine is not initialized. check DATABASE_URL.")

async with async_session_maker() as session:
try:
yield session
# automatic commit/rollback is often handled by caller or service layer logic
# but standard practice for read-heavy apps is just to close.
# If explicit commit is needed, service layer should do it.
except Exception as e:
logger.error(f"Database session error: {e}")
await session.rollback()
raise
finally:
await session.close()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ attrs==25.3.0
auth0-python==4.9.0
Authlib==1.3.1
autoflake==2.3.1
asyncpg==0.29.0
pytest-asyncio==0.23.5
autopep8==2.3.2
backoff==2.2.1
bcrypt==4.3.0
Expand Down
49 changes: 49 additions & 0 deletions docs/DATABASE_CONNECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Database Connection Management

This document describes the thread-safe database connection management implemented for the Devr.AI backend.

## Overview

We use **SQLAlchemy** (AsyncIO) with **asyncpg** to manage a pool of connections to the Supabase PostgreSQL database. This allows for high-concurrency operations without the limitations of HTTP-based PostgREST calls (which `supabase-py` wraps).

## Configuration

The connection manager reads the `DATABASE_URL` from the application settings (loaded from `.env`).

```env
DATABASE_URL=postgresql+asyncpg://user:password@host:5432/dbname
```

## Key Components

### 1. Engine & Pooling
Located in `app/database/core.py`.
- **Pool Size**: 20 connections maintained open.
- **Max Overflow**: 10 temporary connections allowed during high load.
- **Pool Timeout**: 30 seconds wait time before raising an error.
- **Pre-Ping**: Checked before checkout to ensure connection health.

### 2. Dependency Injection
Use `get_db` in FastAPI routes or other async functions to get a session.

```python
from app.database.core import get_db
from sqlalchemy import text

@router.get("/items")
async def read_items(db: AsyncSession = Depends(get_db)):
result = await db.execute(text("SELECT * FROM items"))
return result.mappings().all()
```

The `get_db` generator ensures:
- A session is created from the pool.
- The session is passed to the function.
- The session is **automatically closed** after the function completes (even on error).
- If an error occurs, the transaction is rolled back.

## Testing
Unit tests in `tests/test_db_pool.py` verify:
- Pool configuration.
- Concurrent session acquisition (simulating 50+ parallel requests).
- Proper cleanup (rollback and close) on errors.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ dependencies = [
"pygit2 (>=1.18.2,<2.0.0)",
"toml (>=0.10.2,<0.11.0)",
"websockets (>=15.0.1,<16.0.0)",
"sqlalchemy (>=2.0.25,<3.0.0)",
"asyncpg (>=0.29.0,<1.0.0)",
]

[tool.poetry]
Expand Down
86 changes: 86 additions & 0 deletions tests/test_db_pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from sqlalchemy.ext.asyncio import create_async_engine
import pytest
import asyncio
from unittest.mock import MagicMock, patch

# Mock settings to avoid needing real env vars
with patch("app.core.config.settings") as mock_settings:
mock_settings.database_url = "postgresql+asyncpg://user:password@localhost:5432/testdb"
from app.database.core import engine, get_db
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

@pytest.mark.asyncio
async def test_connection_pooling_configuration():
"""
Verify that the engine is configured with the expected pool size.
"""
# Since we can't easily check internal pool state without a real DB connection
# (which we may not have in this CI/sandbox environment), we inspect the engine settings.

# Check if engine was initialized (it requires DATABASE_URL)
# In this test environment, we might need to manually ensure it's set if the import happened before patch
# But for the sake of unit testing the *code logic*, let's assume valid URL was passed.

if engine:
assert engine.pool.size() == 20
assert engine.pool.timeout() == 30
else:
pytest.skip("Engine not initialized (missing DATABASE_URL)")

@pytest.mark.asyncio
async def test_concurrent_session_acquisition():
"""
Simulate high concurrency to ensure sessions can be acquired without error.
This mocks the actual DB connection to avoid needing a running Postgres.
"""

# Mock the session maker and session
mock_session = MagicMock()
mock_session.close = MagicMock(return_value=asyncio.Future())
mock_session.close.return_value.set_result(None)
mock_session.rollback = MagicMock(return_value=asyncio.Future())
mock_session.rollback.return_value.set_result(None)

# We need to mock the async context manager behavior of the session
mock_session.__aenter__.return_value = mock_session
mock_session.__aexit__.return_value = None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

with patch("app.database.core.async_session_maker", return_value=mock_session) as mock_maker:
async def task():
async for session in get_db():
# Simulate some work
await asyncio.sleep(0.01)
return True

# Run 50 concurrent tasks
results = await asyncio.gather(*[task() for _ in range(50)])

assert all(results)
assert mock_maker.call_count == 50
# Check that sessions were closed (requires digging into the generator,
# but the logic in get_db guarantees close in finally block)
# Verify close was called on the mock session
# Since we yielded the same mock_session 50 times, close should be called 50 times
assert mock_session.close.call_count == 50

@pytest.mark.asyncio
async def test_session_rollback_on_error():
"""
Ensure rollback is called if an exception occurs during session usage.
"""
mock_session = MagicMock()
mock_session.close = MagicMock(return_value=asyncio.Future())
mock_session.close.return_value.set_result(None)
mock_session.rollback = MagicMock(return_value=asyncio.Future())
mock_session.rollback.return_value.set_result(None)
mock_session.__aenter__.return_value = mock_session
mock_session.__aexit__.return_value = None

with patch("app.database.core.async_session_maker", return_value=mock_session):
with pytest.raises(ValueError):
async for session in get_db():
raise ValueError("Simulated Error")

# Verify rollback was called
assert mock_session.rollback.call_count == 1
# Verify close was always called
assert mock_session.close.call_count == 1