Skip to content

Commit 82d2de8

Browse files
Merge pull request #59 from sensein/user_management
Improve performance and make JWT auth non-blocking
2 parents d3f5ea8 + 0dd52f0 commit 82d2de8

8 files changed

Lines changed: 849 additions & 114 deletions

File tree

docker-compose.unified.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ services:
1414
networks:
1515
- brainkb-network
1616
restart: unless-stopped
17+
command: postgres -c max_connections=200 -c shared_buffers=256MB
1718
healthcheck:
1819
test: ["CMD-SHELL", "pg_isready -U ${JWT_POSTGRES_DATABASE_USER:-postgres}"]
1920
interval: 10s
@@ -66,6 +67,8 @@ services:
6667
volumes:
6768
# Use named volume for local development, or custom path via OXIGRAPH_DATA_PATH
6869
# For production with shared storage, set OXIGRAPH_DATA_PATH=/fsx/brainkb-kg-repo in .env
70+
# IMPORTANT: If using bind mount (OXIGRAPH_DATA_PATH set), ensure the path exists and has correct permissions
71+
# The start_services.sh script will validate and create the path if needed
6972
- ${OXIGRAPH_DATA_PATH:-oxigraph_data}:/data
7073
- ${OXIGRAPH_TMP_PATH:-oxigraph_tmp}:/tmp
7174

ml_service/core/database.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import asyncpg
2121
from contextlib import asynccontextmanager
2222
from typing import AsyncGenerator, Optional
23-
from fastapi import HTTPException
23+
from fastapi import HTTPException, status
2424

2525
from core.configuration import load_environment
2626

@@ -66,16 +66,24 @@ async def init_db_pool():
6666
num_workers = int(os.getenv("WEB_CONCURRENCY", "1" if env_state == "development" else "4"))
6767

6868
# Calculate per-worker sizes to avoid exceeding PostgreSQL limits
69-
# Increased pool size to handle concurrent JWT token requests and other operations
70-
# Target: ~90 total connections across all workers (leaves room for other services)
69+
# Increased pool size to handle high concurrent JWT token requests
70+
# After optimization: each token request uses 1 connection (was 2)
71+
# Target: support 100+ concurrent requests safely
7172
if env_state == "development":
7273
min_size = 0 # Lazy init - no connections at startup
7374
max_size = 10
7475
else:
7576
min_size = 2 # Keep a few connections ready for fast JWT validation
76-
# With 6 workers: 15 per worker = 90 total (safe, PostgreSQL default max is 100)
77-
# Increased from 8 to 15 to handle bursts of concurrent requests
78-
max_size = max(10, min(15, 90 // num_workers))
77+
# Calculate pool size to support high concurrency
78+
# Target: 100+ concurrent requests across all workers
79+
# Formula: ensure at least 20-30 connections per worker, but cap at reasonable limit
80+
# Allow environment override via DB_POOL_MAX_SIZE
81+
if os.getenv("DB_POOL_MAX_SIZE"):
82+
max_size = int(os.getenv("DB_POOL_MAX_SIZE"))
83+
else:
84+
# Default: 25 per worker (supports 100 concurrent with 4 workers, 150 with 6)
85+
# PostgreSQL default max_connections is usually 100, but can be increased
86+
max_size = 25
7987

8088
print(f"[DB POOL INIT] Workers: {num_workers}, min={min_size}, max={max_size} per worker")
8189
print(f"[DB POOL INIT] Total potential: {max_size * num_workers} connections")
@@ -280,6 +288,13 @@ async def _insert_logic(connection):
280288
# Manage our own connection
281289
async with get_db_connection() as connection:
282290
return await _insert_logic(connection)
291+
except asyncpg.exceptions.UniqueViolationError as e:
292+
# Handle unique constraint violations (e.g., duplicate email) atomically
293+
logger.warning(f"Registration attempt with duplicate email {email}: {str(e)}")
294+
raise HTTPException(
295+
status_code=status.HTTP_400_BAD_REQUEST,
296+
detail="A user with that email already exists"
297+
)
283298
except Exception as e:
284299
logger.error(f"Error inserting data for user {email}: {str(e)}")
285300
raise HTTPException(status_code=400, detail=str(e))
@@ -331,10 +346,12 @@ async def select_scope_id(conn: Optional[asyncpg.Connection] = None) -> Optional
331346
raise HTTPException(status_code=400, detail=str(e))
332347

333348

334-
async def get_scopes_by_user(user_id: int):
349+
async def get_scopes_by_user(user_id: int, conn: Optional[asyncpg.Connection] = None):
335350
"""
336351
Get all scope names assigned to a user.
337352
Returns a list of scope names.
353+
If conn is provided, uses that connection (caller manages it).
354+
Otherwise, manages its own connection.
338355
"""
339356
query = f"""
340357
SELECT s.name
@@ -344,11 +361,19 @@ async def get_scopes_by_user(user_id: int):
344361
"""
345362

346363
try:
347-
async with get_db_connection() as conn:
364+
if conn is not None:
365+
# Use provided connection
348366
results = await conn.fetch(query, user_id)
349367
assigned_scopes_to_user = [result["name"] for result in results]
350368
logger.debug(f"Scopes for user {user_id}: {assigned_scopes_to_user}")
351369
return assigned_scopes_to_user
370+
else:
371+
# Manage our own connection
372+
async with get_db_connection() as connection:
373+
results = await connection.fetch(query, user_id)
374+
assigned_scopes_to_user = [result["name"] for result in results]
375+
logger.debug(f"Scopes for user {user_id}: {assigned_scopes_to_user}")
376+
return assigned_scopes_to_user
352377
except Exception as e:
353378
logger.error(f"Error getting scopes for user {user_id}: {str(e)}")
354379
raise HTTPException(status_code=400, detail=str(e))

ml_service/core/routers/jwt_auth.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from fastapi import APIRouter, HTTPException, status, Depends
44

5-
from core.database import connect_postgres, get_user, insert_data, get_scopes_by_user
5+
from core.database import get_db_connection, insert_data, get_scopes_by_user
66
from core.models.user import UserIn, LoginUserIn
77
from core.security import get_password_hash, authenticate_user, create_access_token
88

@@ -12,23 +12,32 @@
1212

1313

1414
@router.post("/register", status_code=201)
15-
async def register(user: UserIn, conn=Depends(connect_postgres)):
16-
17-
if await get_user(conn=conn, email=user.email):
18-
raise HTTPException(
19-
status_code=status.HTTP_400_BAD_REQUEST,
20-
detail="A user with that email already exists",
15+
async def register(user: UserIn):
16+
"""
17+
Register a new user. Uses proper connection management to avoid connection leaks.
18+
19+
Note: The unique constraint check is handled atomically by the database.
20+
This prevents race conditions where two concurrent requests could both pass
21+
a pre-check and then both attempt to insert the same email.
22+
"""
23+
async with get_db_connection() as conn:
24+
hashed_password = await get_password_hash(user.password)
25+
26+
# Let the database enforce uniqueness atomically - no pre-check needed
27+
# insert_data will catch UniqueViolationError and return a user-friendly message
28+
return await insert_data(
29+
conn=conn, fullname=user.full_name, email=user.email, password=hashed_password
2130
)
22-
hashed_password = get_password_hash(user.password)
23-
24-
return await insert_data(
25-
conn=conn, fullname=user.full_name, email=user.email, password=hashed_password
26-
)
2731

2832

2933
@router.post("/token")
30-
async def login(user: LoginUserIn, conn=Depends(connect_postgres)):
31-
user = await authenticate_user(user.email, user.password, conn)
32-
scopes = await get_scopes_by_user(user_id=user["id"])
33-
access_token = create_access_token(user["email"], scopes)
34-
return {"access_token": access_token, "token_type": "bearer"}
34+
async def login(user: LoginUserIn):
35+
"""
36+
Authenticate user and return JWT token. Uses proper connection management to avoid connection leaks.
37+
Reuses the same database connection for both authentication and scope retrieval to minimize connection pool usage.
38+
"""
39+
async with get_db_connection() as conn:
40+
authenticated_user = await authenticate_user(user.email, user.password, conn)
41+
scopes = await get_scopes_by_user(user_id=authenticated_user["id"], conn=conn)
42+
access_token = create_access_token(authenticated_user["email"], scopes)
43+
return {"access_token": access_token, "token_type": "bearer"}

ml_service/core/security.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import datetime
2020
import logging
21+
import asyncio
2122
from typing import Annotated, List, Optional, Dict
2223

2324
from fastapi import Depends, HTTPException, status, WebSocket
@@ -56,20 +57,22 @@ def create_access_token(email: str, scopes: List[str]) -> str:
5657
return encoded_jwt
5758

5859

59-
def get_password_hash(password: str) -> str:
60-
return pwd_context.hash(password)
60+
async def get_password_hash(password: str) -> str:
61+
"""Hash password asynchronously to avoid blocking the event loop."""
62+
return await asyncio.to_thread(pwd_context.hash, password)
6163

6264

63-
def verify_password(plain_password: str, hashed_password: str) -> bool:
64-
return pwd_context.verify(plain_password, hashed_password)
65+
async def verify_password(plain_password: str, hashed_password: str) -> bool:
66+
"""Verify password asynchronously to avoid blocking the event loop."""
67+
return await asyncio.to_thread(pwd_context.verify, plain_password, hashed_password)
6568

6669

6770
async def authenticate_user(email, password, conn):
6871
logger.debug("Authenticating user", extra={"email": email})
6972
user = await get_user(conn=conn, email=email)
7073
if not user:
7174
raise credentials_exception
72-
if not verify_password(password, user["password"]):
75+
if not await verify_password(password, user["password"]):
7376
raise credentials_exception
7477
return user
7578

0 commit comments

Comments
 (0)