Postgres connection pool, clean 503 under overload, time indexes, 4 workers
Postgres here allows 60 connections, but each of the 96 gunicorn threads held its own persistent one: at 100 concurrent users (local Postgres with the same limit) 21% of requests were 500 "remaining connection slots are reserved". Now each worker has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460). - A full pool answers 503 + Retry-After (DRF handler and middleware), not 500. - The AI tutor and the OTP SMS call return their connection before the network wait. - statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old DB_CONN_MAX_AGE stop startup instead of failing every request. - /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections. - Indexes on the columns global counters filter by time (weekly leaderboard, global SMS cap, global AI cap); the leaderboard recomputes once per expiry. - Worker count moves into gunicorn.conf.py (4 on a shared 8-core box). - security doc: Redis item closed (server uses our own Redis since 2026-10-08). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -47,3 +48,35 @@ def compute_scores(period, days=7, limit=50, include_zero=False):
|
||||
scores = [(uid, xp) for uid, xp in totals.items() if include_zero or xp > 0]
|
||||
scores.sort(key=lambda p: (-p[1], p[0]))
|
||||
return scores[:limit]
|
||||
|
||||
|
||||
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ همزمان هر بار جدول را از نو حساب نکنند.
|
||||
LEADERBOARD_CACHE_TTL = 60
|
||||
# نسخهی کهنه فقط برای لحظهای است که کسی دیگر در حالِ ساختنِ نسخهی تازه است.
|
||||
_STALE_TTL = 24 * 3600
|
||||
_LOCK_TTL = 30
|
||||
|
||||
|
||||
def cached_scores(period):
|
||||
"""
|
||||
compute_scores با کش، بی «ازدحامِ لحظهی انقضا»: با `get_or_set` ساده، وقتی کش منقضی میشد هر
|
||||
درخواستی که در همان لحظه میرسید خودش کلِ جدول را از نو حساب میکرد. حالا فقط درخواستی که قفل را
|
||||
بگیرد حساب میکند و بقیه آخرین نسخه را (حداکثر چند ثانیه کهنهتر) میگیرند. اگر هیچ نسخهای نیست
|
||||
(اولین بار یا کشِ خالیشده) خودشان حساب میکنند، چون چیزِ دیگری برای نشان دادن نیست.
|
||||
کش Redisِ مشترکِ workerهاست؛ با قطعیِ Redis (config/cache.py) هر درخواست مستقیم حساب میکند.
|
||||
"""
|
||||
fresh_key, stale_key, lock_key = (f'leaderboard:{period}', f'leaderboard:{period}:stale',
|
||||
f'leaderboard:{period}:lock')
|
||||
scores = cache.get(fresh_key)
|
||||
if scores is not None:
|
||||
return scores
|
||||
if cache.add(lock_key, 1, _LOCK_TTL):
|
||||
try:
|
||||
scores = compute_scores(period)
|
||||
cache.set(fresh_key, scores, LEADERBOARD_CACHE_TTL)
|
||||
cache.set(stale_key, scores, _STALE_TTL)
|
||||
finally:
|
||||
cache.delete(lock_key)
|
||||
return scores
|
||||
stale = cache.get(stale_key)
|
||||
return stale if stale is not None else compute_scores(period)
|
||||
|
||||
Reference in New Issue
Block a user