Files
MathApp/progress/leaderboard.py
Zareian feaa5eb540 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>
2026-10-11 20:21:35 +03:30

83 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
امتیازهای جدولِ رتبه‌بندی، مستقیم از فعالیتِ واقعی.
تا ۲۰۲۶-۰۹-۲۴ `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)