Replace unbounded thread spawning with managed worker pool:
- New pypi_cache_tasks table tracks caching jobs
- Thread pool with 5 workers (configurable via ORCHARD_PYPI_CACHE_WORKERS)
- Automatic retries with exponential backoff (30s, 60s, then fail)
- Deduplication to prevent duplicate caching attempts
New API endpoints for visibility and control:
- GET /pypi/cache/status - queue health summary
- GET /pypi/cache/failed - list failed tasks with errors
- POST /pypi/cache/retry/{package} - retry single package
- POST /pypi/cache/retry-all - retry all failed packages
This fixes silent failures in background dependency caching where
packages would fail to cache without any tracking or retry mechanism.
112 lines
3.3 KiB
Python
112 lines
3.3 KiB
Python
from fastapi import FastAPI, Request
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse
|
|
from contextlib import asynccontextmanager
|
|
import logging
|
|
import os
|
|
|
|
from slowapi import _rate_limit_exceeded_handler
|
|
from slowapi.errors import RateLimitExceeded
|
|
|
|
from .config import get_settings
|
|
from .database import init_db, SessionLocal
|
|
from .routes import router
|
|
from .pypi_proxy import router as pypi_router
|
|
from .seed import seed_database
|
|
from .auth import create_default_admin
|
|
from .rate_limit import limiter
|
|
from .pypi_cache_worker import init_cache_worker_pool, shutdown_cache_worker_pool
|
|
|
|
settings = get_settings()
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Startup: initialize database
|
|
init_db()
|
|
|
|
# Create default admin user if no users exist
|
|
db = SessionLocal()
|
|
try:
|
|
admin = create_default_admin(db)
|
|
if admin:
|
|
logger.warning(
|
|
"Default admin user created with username 'admin' and password 'changeme123'. "
|
|
"CHANGE THIS PASSWORD IMMEDIATELY!"
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
# Seed test data in development mode
|
|
if settings.is_development:
|
|
logger.info(f"Running in {settings.env} mode - checking for seed data")
|
|
db = SessionLocal()
|
|
try:
|
|
seed_database(db)
|
|
finally:
|
|
db.close()
|
|
else:
|
|
logger.info(f"Running in {settings.env} mode - skipping seed data")
|
|
|
|
# Initialize PyPI cache worker pool
|
|
init_cache_worker_pool()
|
|
|
|
yield
|
|
|
|
# Shutdown: cleanup
|
|
shutdown_cache_worker_pool()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Orchard",
|
|
description="Content-Addressable Storage System",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Set up rate limiting
|
|
app.state.limiter = limiter
|
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
|
|
# Include API routes
|
|
app.include_router(router)
|
|
app.include_router(pypi_router)
|
|
|
|
# Serve static files (React build) if the directory exists
|
|
static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist")
|
|
if os.path.exists(static_dir):
|
|
app.mount(
|
|
"/assets",
|
|
StaticFiles(directory=os.path.join(static_dir, "assets")),
|
|
name="assets",
|
|
)
|
|
|
|
@app.get("/")
|
|
async def serve_spa():
|
|
return FileResponse(os.path.join(static_dir, "index.html"))
|
|
|
|
# Catch-all for SPA routing (must be last)
|
|
@app.get("/{full_path:path}")
|
|
async def serve_spa_routes(full_path: str):
|
|
# Don't catch API routes or health endpoint
|
|
if full_path.startswith("api/") or full_path.startswith("health"):
|
|
from fastapi import HTTPException
|
|
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
|
|
# Check if requesting a static file from dist root (favicon, etc.)
|
|
static_file_path = os.path.join(static_dir, full_path)
|
|
if os.path.isfile(static_file_path) and not full_path.startswith("."):
|
|
return FileResponse(static_file_path)
|
|
|
|
# Serve SPA for all other routes (including /project/*)
|
|
index_path = os.path.join(static_dir, "index.html")
|
|
if os.path.exists(index_path):
|
|
return FileResponse(index_path)
|
|
|
|
from fastapi import HTTPException
|
|
|
|
raise HTTPException(status_code=404, detail="Not found")
|