Skip to content

Commit 0b9df61

Browse files
committed
Refactor RoadmapService to use async database operations
- Replaced synchronous database calls with asynchronous counterparts in RoadmapService. - Introduced a new helper method _run_db to handle blocking database operations in a non-blocking manner. - Removed deprecated scripts for diagnosing schema, fixing missing columns, and running migrations.
1 parent c8e8f7f commit 0b9df61

File tree

8 files changed

+449
-980
lines changed

8 files changed

+449
-980
lines changed

commitly-backend/app/api/roadmap.py

Lines changed: 25 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import math
34
from typing import Optional
45

56
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
@@ -56,114 +57,27 @@ async def list_roadmaps(
5657
service: RoadmapService = Depends(get_roadmap_service),
5758
) -> CatalogPage:
5859
"""List catalog with filters, sorting, and pagination."""
59-
import logging
60-
import math
61-
62-
logger = logging.getLogger(__name__)
63-
try:
64-
logger.info(f"list_roadmaps: Starting with page={page}, page_size={page_size}")
65-
66-
# Call service method - wrap sync database call in executor
67-
# SQLAlchemy sessions are not thread-safe, so we must use executor carefully
68-
import asyncio
69-
import time
70-
71-
start_time = time.time()
72-
logger.info("list_roadmaps: Calling service.list_catalog")
73-
74-
# Wrap the sync database call in executor to avoid blocking event loop
75-
# The service.list_catalog is async but calls sync DB code internally
76-
def call_db():
77-
try:
78-
logger.info("list_roadmaps.executor: Starting database call")
79-
# Use the service's internal result_store directly for executor
80-
# This avoids issues with async/sync boundaries
81-
if hasattr(service, "_result_store"):
82-
return service._result_store.list_catalog(
83-
page=page,
84-
page_size=page_size,
85-
language=language,
86-
tag=tag,
87-
difficulty=difficulty,
88-
min_rating=min_rating,
89-
min_views=min_views,
90-
min_syncs=min_syncs,
91-
sort=sort,
92-
)
93-
else:
94-
# For test stubs, fallback to async service method
95-
# This won't work in executor, but should work in tests
96-
raise RuntimeError(
97-
"Service doesn't have _result_store and executor can't "
98-
"call async methods"
99-
)
100-
except Exception as e:
101-
elapsed = time.time() - start_time
102-
logger.error(
103-
f"list_roadmaps.executor: Error after {elapsed:.2f}s - "
104-
f"{type(e).__name__}: {e}",
105-
exc_info=True,
106-
)
107-
raise
108-
109-
# Check if we have _result_store (production) or need to use async method (tests)
110-
if hasattr(service, "_result_store"):
111-
# Production: Use executor for sync DB call
112-
loop = asyncio.get_event_loop()
113-
try:
114-
items, total_count = await asyncio.wait_for(
115-
loop.run_in_executor(None, call_db),
116-
timeout=25.0, # 25 second timeout (less than Render's 30s)
117-
)
118-
elapsed = time.time() - start_time
119-
logger.info(
120-
f"list_roadmaps: Query completed in {elapsed:.2f}s - "
121-
f"Retrieved {len(items)} items, total={total_count}"
122-
)
123-
except asyncio.TimeoutError:
124-
elapsed = time.time() - start_time
125-
logger.error(
126-
f"list_roadmaps: Database query timed out after {elapsed:.2f}s"
127-
)
128-
raise HTTPException(
129-
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
130-
detail="Database query timed out",
131-
)
132-
else:
133-
# Tests: Use async service method directly
134-
logger.info("list_roadmaps: Using async service method (test mode)")
135-
items, total_count = await service.list_catalog(
136-
page=page,
137-
page_size=page_size,
138-
language=language,
139-
tag=tag,
140-
difficulty=difficulty,
141-
min_rating=min_rating,
142-
min_views=min_views,
143-
min_syncs=min_syncs,
144-
sort=sort,
145-
)
146-
147-
total_pages = math.ceil(total_count / page_size) if total_count > 0 else 0
148-
149-
return CatalogPage(
150-
items=items,
151-
page=page,
152-
page_size=page_size,
153-
total_count=total_count,
154-
total_pages=total_pages,
155-
)
156-
except HTTPException:
157-
raise
158-
except Exception as e:
159-
logger.error(
160-
f"list_roadmaps: Unexpected error - {type(e).__name__}: {e}",
161-
exc_info=True,
162-
)
163-
raise HTTPException(
164-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
165-
detail=f"Failed to retrieve catalog: {str(e)}",
166-
) from e
60+
items, total_count = await service.list_catalog(
61+
page=page,
62+
page_size=page_size,
63+
language=language,
64+
tag=tag,
65+
difficulty=difficulty,
66+
min_rating=min_rating,
67+
min_views=min_views,
68+
min_syncs=min_syncs,
69+
sort=sort,
70+
)
71+
72+
total_pages = math.ceil(total_count / page_size) if total_count > 0 else 0
73+
74+
return CatalogPage(
75+
items=items,
76+
page=page,
77+
page_size=page_size,
78+
total_count=total_count,
79+
total_pages=total_pages,
80+
)
16781

16882

16983
@router.get("/cached/{owner}/{repo}", response_model=RoadmapResponse)
@@ -276,7 +190,9 @@ async def set_repository_rating(
276190
current_user: ClerkClaims = Depends(require_clerk_auth),
277191
service: RoadmapService = Depends(get_roadmap_service),
278192
) -> RatingResponse:
279-
return await service.set_rating(get_user_id(current_user), owner, repo, payload.rating)
193+
return await service.set_rating(
194+
get_user_id(current_user), owner, repo, payload.rating
195+
)
280196

281197

282198
@router.get("/{owner}/{repo}/rating", response_model=RatingResponse | None)

commitly-backend/app/core/auth.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ async def verify_clerk_token_async(token: str) -> ClerkClaims:
242242

243243
# Use async version to avoid blocking
244244
jwk_data = await jwks_cache.get_key_async(kid)
245-
logger.info("verify_clerk_token_async: Got JWKS data, starting signature verification")
245+
logger.info(
246+
"verify_clerk_token_async: Got JWKS data, starting signature verification"
247+
)
246248

247249
# Run CPU-intensive JWT operations in executor to avoid blocking
248250
def verify_signature():
@@ -297,7 +299,9 @@ def verify_signature():
297299
]
298300
if audience_values:
299301
if not any(audience in audience_values for audience in allowed_audiences):
300-
logger.error(f"verify_clerk_token_async: Invalid audience {audience_values}")
302+
logger.error(
303+
f"verify_clerk_token_async: Invalid audience {audience_values}"
304+
)
301305
raise InvalidClerkToken("Invalid audience")
302306

303307
if settings.clerk_authorized_parties:
@@ -308,7 +312,9 @@ def verify_signature():
308312
_normalize_party(party) for party in settings.clerk_authorized_parties
309313
}
310314
if "*" not in allowed and normalized_azp not in allowed:
311-
logger.error(f"verify_clerk_token_async: Unauthorized party {normalized_azp}")
315+
logger.error(
316+
f"verify_clerk_token_async: Unauthorized party {normalized_azp}"
317+
)
312318
raise InvalidClerkToken("Token not issued for this application")
313319
else:
314320
logger.error("verify_clerk_token_async: Missing authorized party")
@@ -401,14 +407,16 @@ async def dispatch(
401407
# Use async version to avoid blocking the event loop
402408
# Add timeout to prevent hanging
403409
request.state.clerk_claims = await asyncio.wait_for(
404-
verify_clerk_token_async(token),
405-
timeout=10.0
410+
verify_clerk_token_async(token), timeout=10.0
406411
)
407412
except asyncio.TimeoutError:
408413
import logging
414+
409415
logger = logging.getLogger(__name__)
410416
logger.error("Clerk token verification timed out after 10 seconds")
411-
request.state.clerk_auth_error = InvalidClerkToken("Token verification timeout")
417+
request.state.clerk_auth_error = InvalidClerkToken(
418+
"Token verification timeout"
419+
)
412420
except InvalidClerkToken as exc:
413421
request.state.clerk_auth_error = exc
414422

commitly-backend/app/main.py

Lines changed: 32 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -38,70 +38,29 @@ def _configure_logging() -> None:
3838

3939
@asynccontextmanager
4040
async def lifespan(app: FastAPI):
41-
"""Handle application startup and shutdown."""
41+
"""
42+
Keep startup lightweight: only verify we can talk to the database.
43+
Heavy schema fixes belong in Alembic migrations (run separately).
44+
"""
4245
import asyncio
4346

44-
# Startup: Test database connection
45-
logger.info("Testing database connection...")
46-
try:
47-
def test_connection():
47+
async def ping_database() -> None:
48+
def _ping():
4849
with SessionLocal() as session:
4950
session.execute(text("SELECT 1"))
50-
session.commit()
5151

52-
# Run in executor to avoid blocking
5352
loop = asyncio.get_event_loop()
54-
await asyncio.wait_for(
55-
loop.run_in_executor(None, test_connection),
56-
timeout=5.0
57-
)
58-
logger.info("✅ Database connection successful")
59-
except asyncio.TimeoutError:
60-
logger.error("❌ Database connection timeout after 5 seconds")
61-
except Exception as e:
62-
logger.error(f"❌ Database connection failed: {e}", exc_info=True)
53+
await loop.run_in_executor(None, _ping)
6354

64-
# Verify schema: Check if required columns exist
65-
# Note: Migrations are run in pre-deploy script, not here
66-
logger.info("Verifying database schema...")
6755
try:
68-
def verify_schema():
69-
with SessionLocal() as session:
70-
# Check if primary_language column exists
71-
result = session.execute(
72-
text(
73-
"""
74-
SELECT column_name
75-
FROM information_schema.columns
76-
WHERE table_name = 'generated_roadmaps'
77-
AND column_name = 'primary_language'
78-
"""
79-
)
80-
)
81-
return result.fetchone() is not None
82-
83-
# Run in executor to avoid blocking
84-
loop = asyncio.get_event_loop()
85-
column_exists = await asyncio.wait_for(
86-
loop.run_in_executor(None, verify_schema),
87-
timeout=5.0
88-
)
89-
90-
if column_exists:
91-
logger.info("✅ Database schema verified - all required columns exist")
92-
else:
93-
logger.warning(
94-
"⚠️ Required columns missing. Pre-deploy script should have fixed this. "
95-
"If this persists, check pre-deploy command execution."
96-
)
56+
await asyncio.wait_for(ping_database(), timeout=5.0)
57+
logger.info("✅ Database connection healthy at startup")
9758
except asyncio.TimeoutError:
98-
logger.error("❌ Schema verification timeout after 5 seconds")
99-
except Exception as e:
100-
logger.error(f"❌ Schema verification failed: {e}", exc_info=True)
59+
logger.error("❌ Database ping timed out (5s)")
60+
except Exception as exc: # pragma: no cover - startup diagnostic
61+
logger.error(f"❌ Database ping failed: {exc}", exc_info=True)
10162

102-
logger.info("🚀 Application startup complete")
10363
yield
104-
# Shutdown
10564
logger.info("Application shutting down")
10665

10766

@@ -116,25 +75,36 @@ def verify_schema():
11675
)
11776

11877
# Add CORS middleware
119-
# Ensure allowed_origins is always a list
120-
# (validator should handle this, but type-safe check)
12178
if isinstance(settings.allowed_origins, str):
122-
cors_origins = [settings.allowed_origins] if settings.allowed_origins else ["*"]
79+
configured_origins = [settings.allowed_origins] if settings.allowed_origins else []
12380
else:
124-
cors_origins = list(settings.allowed_origins or ["*"])
81+
configured_origins = list(settings.allowed_origins or [])
12582

12683
local_dev_origins = {
12784
"http://localhost:3000",
12885
"http://localhost:3700",
12986
}
130-
for origin in local_dev_origins:
131-
if origin not in cors_origins:
132-
cors_origins.append(origin)
87+
88+
cors_origins = sorted({*configured_origins, *local_dev_origins} - {None, ""})
89+
90+
allow_origin_regex = None
91+
allow_credentials = True
92+
93+
if "*" in configured_origins:
94+
# FastAPI disallows credentials when using wildcard origins. Fall back to
95+
# a permissive regex and explicitly disable credentials to avoid misconfig.
96+
cors_origins = sorted({*configured_origins, *local_dev_origins} - {"*", None, ""})
97+
allow_origin_regex = r"https?://.*"
98+
allow_credentials = False
99+
logger.warning(
100+
"CORS wildcard detected. Falling back to regex with credentials disabled."
101+
)
133102

134103
app.add_middleware(
135104
CORSMiddleware,
136-
allow_origins=cors_origins or ["*"],
137-
allow_credentials=True,
105+
allow_origins=cors_origins,
106+
allow_origin_regex=allow_origin_regex,
107+
allow_credentials=allow_credentials,
138108
allow_methods=["*"],
139109
allow_headers=["*"],
140110
)

0 commit comments

Comments
 (0)