Scaling Audit — CRTX (10 → 100–1000 concurrent users)
Audit date: 2026-07-02. Deploy target: Vercel (frontend) + Railway (backend, single
uvicorn process, no --workers), Supabase (Postgres + auth + storage), Pinecone (vectors).
The architecture is fundamentally sound for scale: ingestion is offloaded to an arq/Redis worker, Pinecone absorbs vector load, streaming is async, and Supabase/OpenAI/Pinecone clients are module-level singletons rather than per-request. The bottlenecks are concentrated in the query hot path and a few whole-table reads.
Findings are ranked by severity. #1 has been fixed — see
backend/app/routers/query.py (the three pre-stream
Supabase calls are now wrapped in asyncio.to_thread). The rest are documented below for
later work.
#✅ 1. (FIXED) Blocking Supabase calls inside the async def query endpoint
query() is async but made three synchronous, blocking Supabase HTTP round-trips
(_can_query, _get_collection_config, _get_session_history) directly on the single
event-loop thread before streaming. With one uvicorn process / one event loop, each of
those froze all other concurrent requests, including in-flight token streaming.
Fixed by wrapping each call in asyncio.to_thread. Still recommended as a follow-up:
run uvicorn with multiple workers (--workers N) so a single process is not the ceiling.
#2. list_users() reads the entire auth table into memory on every members/shares view
Files: backend/app/routers/chat.py:123-124 (get_collection_members),
backend/app/routers/collections.py:139-140 (list_shares).
_db.auth.admin.list_users() fetches all auth users and filters in Python to build an
email map.
- Doesn't scale: O(total users) work + full-table transfer on every "view members" / "view shares" request, independent of the collection's actual member count.
- Already subtly broken past ~50 users: Supabase
list_userspaginates (default 50/page); this code reads only page 1, so beyond 50 total users the email map silently drops people. Looping all pages to "fix" it makes the scaling problem worse.
Fix direction: look up only the specific candidate_ids (a targeted batch query by id),
never enumerate the whole user base.
#✅ 3. (FIXED) Per-query LLM eval scoring + title generation ran in the web process threadpool
Every query scheduled a background task making up to 5 sequential OpenAI calls (title
generation with up to 3 retries + 2 gpt-4o-mini eval scorers) plus blocking Supabase writes.
FastAPI runs sync background tasks in the shared ~40-thread Starlette threadpool — the same
pool that serves every sync def route — so under concurrency the pool saturated and plain
CRUD endpoints queued behind eval scoring.
Fixed: the persist + score work moved to the arq worker.
- New job module:
backend/app/jobs/query_followup.py(persist_and_score+ arq wrapperpersist_query_result);_save_exchange/_generate_titlemoved here out of the router. - Registered in
backend/app/worker.py. backend/app/routers/query.pyafter_streamis now async and enqueues the job (a fast Redis call). If Redis is unavailable it falls back to running the work inline viaasyncio.to_thread, so chat history is still saved (degraded but correct).
Deploy note: this makes the arq worker part of the query path, not just ingestion — so the worker process must be running in production (see the operational aside below).
#4. Missing indexes on the collections-list hot path → sequential scans
Verified live (Supabase project crux / lzmkprrszsvfndgdjzhq, 2026-07-02):
collectionshas only its primary key — no index onuser_id.collection_membershas composite unique(collection_id, user_id), which does not serve lookups filtered byuser_idalone.
list_collections (backend/app/routers/collections.py:70-72) filters both tables by
user_id on every dashboard load → two sequential scans. Negligible today (tiny tables),
but grows linearly with total collections/memberships across all tenants.
Fix direction:
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections (user_id);
CREATE INDEX IF NOT EXISTS idx_collection_members_user_id ON collection_members (user_id);
(Lower-priority companion: collection_shares.collection_id has no dedicated index either;
add one if the shares list becomes hot.)
#5. get_eval_stats pulls 500 full rows and aggregates in Python
File: backend/app/routers/evals.py:29-45.
Each analytics load selects up to 500 rag_evals rows with select("*") — including full
retrieved_chunks JSON blobs and answer text — then computes averages/trend/worst-10 in
Python. Heavy payload + CPU that grows with eval volume.
Fix direction: push aggregation into SQL (avg(), group by date, order by ... limit)
via a Postgres view or RPC; return only what the dashboard renders. Lower severity because
it's a dashboard/admin endpoint, not per-user query traffic.
#Deliberately NOT flagged (scale fine at 1000)
- JWKS client cache (
backend/app/auth.py) — in-memory, per-instance, correct. lru_cache'd Pinecone index (backend/app/db/vector_store.py).- Module-level Supabase/OpenAI client singletons.
- The arq ingestion pipeline itself.
Operational aside (not a scaling bottleneck, but verify): the arq worker is not in
backend/Procfile (only web:). Confirm it runs as a separate Railway service, or
ingestion silently never processes.