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>
83 lines
4.2 KiB
Python
83 lines
4.2 KiB
Python
"""
|
||
امتیازهای جدولِ رتبهبندی، مستقیم از فعالیتِ واقعی.
|
||
|
||
تا ۲۰۲۶-۰۹-۲۴ `GET /api/leaderboard` فقط جدولِ LeaderboardEntry را میخواند که
|
||
تنها با اجرای دستیِ `rebuild_leaderboard` پر میشد؛ XPی که کاربرِ تازه میگرفت
|
||
هیچوقت در جدول دیده نمیشد. حالا view همین تابع را صدا میزند و نتیجه را
|
||
کوتاهمدت کش میکند، و دستورِ rebuild هم از همین تابع میخواند تا دو منبعِ
|
||
عدد از هم جدا نیفتند.
|
||
|
||
- `all_time`: همان `UserStats.xp` که در `/api/user/summary` هم دیده میشود.
|
||
- `weekly`: XPی درسها و جزیرههایی که در `days` روزِ اخیر تمام شدهاند —
|
||
همان دو جایی که `UserStats.xp` زیاد میشود. `UserAnswer.xp_earned` عمداً
|
||
شمرده نمیشود چون به `UserStats.xp` اضافه نمیشود.
|
||
"""
|
||
from collections import defaultdict
|
||
|
||
from django.core.cache import cache
|
||
from django.db.models import Sum
|
||
from django.utils import timezone
|
||
|
||
PERIODS = ('weekly', 'all_time')
|
||
|
||
|
||
def compute_scores(period, days=7, limit=50, include_zero=False):
|
||
"""[(user_id, xp), ...] مرتب: XPی بیشتر جلوتر، در تساوی user_id کوچکتر.
|
||
پیشفرض فقط XPی مثبت؛ include_zero برای دستورِ rebuild است."""
|
||
if period == 'all_time':
|
||
from progress.models import UserStats
|
||
qs = UserStats.objects.all() if include_zero else UserStats.objects.filter(xp__gt=0)
|
||
rows = list(
|
||
qs.order_by('-xp', 'user_id')
|
||
.values_list('user_id', 'xp')[:limit]
|
||
)
|
||
return [(uid, xp) for uid, xp in rows]
|
||
|
||
if period != 'weekly':
|
||
return []
|
||
|
||
from content.models import UserLessonCompletion, UserNodeCompletion
|
||
since = timezone.now() - timezone.timedelta(days=days)
|
||
totals = defaultdict(int)
|
||
for model in (UserLessonCompletion, UserNodeCompletion):
|
||
for row in (
|
||
model.objects.filter(created_at__gte=since)
|
||
.values('user_id').annotate(total=Sum('xp_earned'))
|
||
):
|
||
totals[row['user_id']] += row['total'] or 0
|
||
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)
|