Spaces:
Runtime error
Scaling Reference
Purpose: What to know and what to change when traffic grows. Written at V1 (sync, low-traffic SME demo). Read this before making any scaling decision.
Current Architecture (V1 Baseline)
| Layer | Technology | Limit |
|---|---|---|
| Web framework | FastAPI (sync, thread pool) | ~200β500 concurrent requests before thread exhaustion |
| DB driver | psycopg2 (sync) | Fine up to ~100 concurrent DB queries |
| DB | PostgreSQL (single instance) | Fine up to ~500β1000 connections with PgBouncer |
| LLM | Groq (sync HTTP call, 1β3s) | Real bottleneck β async won't help here |
| Vector DB | ChromaDB (in-process) | Fine for low-volume; becomes a bottleneck at scale |
| Deployment | Single Docker container | No horizontal scaling |
The Real Bottleneck: LLM Latency
Every /chat request waits 1β3 seconds for the LLM response. At V1 traffic (5β50 concurrent users), this is fine. At scale:
- Async DB saves ~5ms per request
- LLM call costs 1,000β3,000ms per request
Conclusion: Optimizing the DB layer before solving LLM latency is premature. Address LLM first.
Scale Thresholds and What to Do
< 100 concurrent users β Do nothing
Current sync architecture handles this comfortably. Focus on product.
100β500 concurrent users
- Add PgBouncer β connection pooling in front of PostgreSQL. Prevents DB connection exhaustion. Config change only, no code change.
- Add Redis caching β cache system prompts and KB entries per tenant. Eliminates redundant DB reads on every
/chatcall. - Horizontal scale β run 2β3 replicas behind a load balancer (Nginx/Traefik). Stateless FastAPI app supports this with zero code changes.
500β5,000 concurrent users
- LLM request queue β move LLM calls to a background worker (Celery + Redis).
/chatreturns a job ID immediately; client polls for the result. Eliminates thread exhaustion from long-running LLM calls. - ChromaDB β managed vector DB β replace in-process ChromaDB with Qdrant or Weaviate (dedicated service). In-process ChromaDB doesn't scale horizontally.
- Read replicas β add PostgreSQL read replicas for conversation history and KB reads.
> 5,000 concurrent users
- Async migration β switch to
asyncpgdriver + async SQLAlchemy + async FastAPI. Requires rewriting allget_dbdependencies, session management, and test fixtures. High cost β only justified at this scale. - Kubernetes β horizontal pod autoscaling, per-tenant rate limiting at ingress, proper resource limits.
- LLM provider strategy β provider failover, per-model rate limits, tenant-level concurrency caps.
Async Migration Path (when justified)
When async is finally needed, the migration order is:
- Change
DATABASE_URLscheme:postgresql://βpostgresql+asyncpg:// - Replace
SessionLocalwithAsyncSessionindatabase.py - Replace
get_dbdependency with async generator - Change all router functions from
deftoasync def - Replace all
db.scalars(...)withawait db.scalars(...) - Rewrite all test fixtures in
conftest.py(async session, async client) - Run full test suite β expect failures in every file that touches the DB
Cost: ~2β3 days of pure refactoring with no new features. Only do this when load data proves it's needed.
One-Way Doors to Avoid Before Scale
These decisions become very expensive to undo at scale β make them correctly from the start:
| Decision | Risk if wrong |
|---|---|
No credit_events table |
Can't reconstruct billing history β dispute with no evidence |
| No tenant isolation in ChromaDB | Data leakage between tenants β legal liability |
No API versioning (/api/v1/) |
Can never change request/response contract without breaking all clients |
No llm_models versioned rates |
Can't prove which rate applied to a historical charge |
Summary
Sync is fine for V1 and beyond. The LLM call is the bottleneck, not the DB driver. Add PgBouncer + Redis + horizontal replicas before ever touching async. Migrate to async only when data shows you need it β and budget 2β3 days for the rewrite.